Index: src/assets/.well-known/assetlinks.json
===================================================================
--- src/assets/.well-known/assetlinks.json	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/assets/.well-known/assetlinks.json	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -0,0 +1,12 @@
+[
+    {
+        "relation": ["delegate_permission/common.handle_all_urls"],
+        "target": {
+            "namespace": "android_app",
+            "package_name": "com.roboburr.app",
+            "sha256_cert_fingerprints": [
+                "92:2F:55:C4:36:4C:8D:DA:99:0C:63:18:BA:78:A3:76:AD:B9:AD:77:E5:AA:68:54:16:FE:FB:28:62:11:EB:DB"
+            ]
+        }
+    }
+]
Index: src/assets/css/audio.css
===================================================================
--- src/assets/css/audio.css	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/assets/css/audio.css	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,3 +1,3 @@
-/* Klonkt — Audio CSS
+/* PrutCMS v10 — Audio CSS
    Two pieces:
      1. .post-audio-track — the play-button block inserted by [[track:id]] shortcode
@@ -73,52 +73,4 @@
   text-overflow: ellipsis;
 }
-/* Eigenaar/credit · licentie — subtiele regel onder de track. */
-.post-audio-track .pat-credit {
-  font-size: 0.72rem;
-  color: var(--ink-faint, var(--ink-soft));
-  margin-top: 0.1rem;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-/* "Open in" Spotify/YouTube/SoundCloud — kleine brand-iconen per track. */
-.pat-links {
-  display: inline-flex; align-items: center; gap: 0.1rem;
-  flex: 0 0 auto;
-}
-/* Single + album-rij (.post-audio-track is flex) → duw de links naar rechts. */
-.post-audio-track .pat-links { margin-left: auto; padding-right: 0.5rem; }
-/* Playlist-rij: de li flex maken zodat de links naast de afspeel-rij passen. */
-.post-album-track-compact { display: flex; align-items: center; }
-.post-album-track-compact .pat-row { flex: 1 1 auto; min-width: 0; }
-.post-album-track-compact .pat-links { padding-right: 0.6rem; }
-.pat-link {
-  display: inline-flex; align-items: center; justify-content: center;
-  width: 26px; height: 26px;
-  opacity: 0.75;
-  transition: opacity 120ms, transform 120ms;
-}
-.pat-link svg { width: 17px; height: 17px; }
-.pat-link:hover { opacity: 1; transform: scale(1.12); }
-.pat-link--spotify    { color: #1DB954; }
-.pat-link--youtube    { color: #FF0000; }
-.pat-link--soundcloud { color: #FF5500; }
-
-/* Link-only tracks (geen audiobestand): plek van de afspeelknop = stil muziek-icoon. */
-.pat-noplay {
-  display: inline-flex; align-items: center; justify-content: center;
-  flex: 0 0 38px; width: 38px; align-self: center;
-  color: var(--ink-faint, #a8a29e);
-}
-.pat-noplay svg { width: 18px; height: 18px; }
-/* Cover-thumbnail op een link-only track (i.p.v. het muzieknootje). */
-.pat-noplay--cover {
-  width: 64px; height: 64px; flex: 0 0 64px;
-  border-radius: 8px;
-  background-size: cover; background-position: center;
-  margin: 0 .5rem 0 0;
-}
-.pat-row.pat-static { cursor: default; }
 
 /* ============================================================
@@ -151,13 +103,4 @@
   }
 }
-/* Het auth/login-focusscherm rendert GEEN bottom-tab (zie shell.ejs), maar de body
-   houdt wel de has-bottom-tab-class → zonder dit zou de mini-player 56px boven de
-   onderrand zweven boven een niet-bestaande tab. Reset naar bottom:0 (alle breedtes;
-   op desktop is 't sowieso al 0). Robuuste class-selector — geen :has() in deze
-   kritieke regel. */
-body.on-auth .audio-player {
-  bottom: 0;
-  padding-bottom: env(safe-area-inset-bottom, 0);
-}
 [data-theme="dark"] .audio-player {
   background: color-mix(in srgb, var(--paper, #1a1a1a) 92%, #fff);
@@ -168,18 +111,4 @@
   opacity: 0;
   pointer-events: none;
-}
-
-/* Browser-autoplay-policy heeft de auto-advance gestopt — typisch
-   na 3-4 tracks op iOS Safari. Visuele hint dat user op play moet
-   tappen om door te gaan (PCMS v10.1, audio-player.js). */
-.audio-player.audio-needs-tap .audio-btn-play {
-  animation: audio-pulse-tap 1.2s ease-in-out infinite;
-  background: rgba(255, 165, 0, 0.18);
-  border-color: orange;
-  color: orange;
-}
-@keyframes audio-pulse-tap {
-  0%, 100% { box-shadow: 0 0 0 0 rgba(255, 165, 0, 0.5); }
-  50%      { box-shadow: 0 0 0 8px rgba(255, 165, 0, 0); }
 }
 .audio-player-inner {
@@ -205,10 +134,4 @@
 .audio-player-expand-trigger:hover {
   background: color-mix(in srgb, var(--ink, #000) 6%, transparent);
-}
-/* DESKTOP (≥1200px): geen full player — de expand-trigger opent niets (JS),
-   dus 'm niet als klikbaar laten ogen. */
-@media (min-width: 1200px) {
-  .audio-player-expand-trigger { cursor: default; }
-  .audio-player-expand-trigger:hover { background: none; }
 }
 
@@ -413,6 +336,6 @@
 body.audio-sheet-locked { overflow: hidden; }
 
-/* TELEFOON (<768px): full-width sheet, slide up from bottom */
-@media (max-width: 767.98px) {
+/* MOBILE (<720px): full-width sheet, slide up from bottom */
+@media (max-width: 719.98px) {
   .audio-sheet-panel {
     /* Real full-screen, edge-to-edge, no rounded corners or shadow */
@@ -434,6 +357,22 @@
 }
 
-/* (De grote tablet/auto-variant staat onderaan deze sectie — NÁ de basis cover/
-   info/controls-regels — anders overschrijft de basis die overrides.) */
+/* DESKTOP (≥720px): 480px wide, horizontally centered (v9 design intent).
+   We MUST set width here because v9's style.css sets width:480px and
+   we don't want left/right:0 stretching to override it.
+   The -50% horizontal centering is preserved during drag by composing
+   --pcms-drag-y into the existing translate(-50%, ...) transform. */
+@media (min-width: 720px) {
+  .audio-sheet-panel {
+    left: 50%;
+    right: auto;
+    width: 480px;
+    transform: translate(-50%, 100%);
+    border-top-left-radius: 20px;
+    border-top-right-radius: 20px;
+  }
+  .audio-sheet.is-open .audio-sheet-panel {
+    transform: translate(-50%, var(--pcms-drag-y, 0px));
+  }
+}
 
 .audio-sheet-panel.is-dragging {
@@ -583,48 +522,4 @@
 }
 
-/* TABLET / AUTO (768–1199px): grote, landscape, knop-vriendelijke full-player
-   voor tablets + een tablet-in-de-auto. Bewust full-screen + grote touch-targets.
-   STAAT BEWUST NA de basis-sheet-regels hierboven, anders overschrijft de basis
-   deze overrides (cascade-volgorde). Desktop (≥1200px) opent de full player NIET
-   (afgehandeld in audio-player.js), dus daar is geen sheet-styling nodig. */
-@media (min-width: 768px) and (max-width: 1199.98px) {
-  .audio-sheet-panel {
-    inset: 0; left: 0; right: 0; width: auto;
-    max-height: none;
-    border-radius: 0;
-    box-shadow: none;
-    transform: translateY(100%);
-    padding: clamp(2rem, 5vh, 4rem) clamp(2rem, 6vw, 5rem);
-    gap: clamp(1.25rem, 3vh, 2.5rem);
-    /* 'safe center': verticaal centreren als alles past, maar bovenaan uitlijnen
-       (geen clip) zodra cover + queue hoger zijn dan het scherm — anders wordt de
-       cover boven de viewport geduwd en is 'ie niet te scrollen. */
-    justify-content: safe center;
-    align-items: center;
-  }
-  .audio-sheet.is-open .audio-sheet-panel { transform: translateY(var(--pcms-drag-y, 0px)); }
-
-  .audio-sheet-drag-zone { align-items: center; width: 100%; margin: 0; padding: 0; }
-  .audio-sheet-cover { width: min(48vh, 460px); max-width: none; border-radius: 20px; }
-  .audio-sheet-cover svg { width: 26%; height: 26%; }
-  .audio-sheet-info { width: 100%; max-width: 660px; }
-  .audio-sheet-title  { font-size: clamp(2rem, 4vw, 3rem); }
-  .audio-sheet-artist { font-size: clamp(1.1rem, 2vw, 1.5rem); margin-top: .5rem; }
-  .audio-sheet-album  { font-size: 1rem; margin-top: .25rem; }
-  .audio-sheet-progress { width: 100%; max-width: 660px; gap: 1.25rem; }
-  .audio-sheet-progress .audio-time { font-size: 1.05rem; min-width: 3.2em; }
-  .audio-sheet-controls { gap: clamp(1.5rem, 5vw, 3.5rem); }
-  .audio-sheet-btn { width: 76px; height: 76px; }
-  .audio-sheet-btn svg { width: 34px; height: 34px; }
-  .audio-sheet-play { width: 108px; height: 108px; }
-  .audio-sheet-play svg { width: 46px; height: 46px; }
-  /* Queue dezelfde breedte als info/progress (anders krimpt 'ie door
-     align-items:center tot z'n inhoud = smal), + grotere rijen voor touch. */
-  .audio-sheet-queue { width: 100%; max-width: 660px; }
-  .audio-sheet-queue-label { font-size: 0.95rem; }
-  .audio-sheet-queue-item { padding: 0.7rem 0.7rem; gap: 0.6rem; font-size: 1.05rem; }
-  .audio-sheet-queue-item .aqi-artist { font-size: 0.95rem; }
-}
-
 /* ============================================================
    External-link variant ([[link:url]])
Index: src/assets/css/embed.css
===================================================================
--- src/assets/css/embed.css	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,284 +1,0 @@
-/* ============================================================
- * embed.css — eigen, in-huisstijl media-embeds (embed-player.js)
- * YouTube/SoundCloud = volledig eigen controls; Spotify = onze frame + hun UI.
- * Gebruikt de site-tokens (--paper/--ink/--accent/--rule/--radius) → past zich
- * automatisch aan elk thema (light/dark/varianten) aan.
- * ============================================================ */
-
-.folio-embed.pcms-embed-card {
-  margin: 2rem 0;
-  border: 1px solid var(--rule, #e8e2d5);
-  border-radius: var(--radius, 8px);
-  background: var(--paper-2, #f2ede0);
-  overflow: hidden;
-  position: relative;
-}
-
-/* Placeholder vóór embed-player.js de kaart opbouwt (of als JS uitstaat). */
-.pcms-embed-card.pcms-embed-loading {
-  min-height: 64px;
-}
-/* Fallback-tekst zolang JS de kaart nog niet heeft opgebouwd (of als JS uitstaat).
- * buildCard() haalt .pcms-embed-loading weg → deze ::after verdwijnt vanzelf. */
-.pcms-embed-card.pcms-embed-loading::after {
-  content: "Speler laden\2026";
-  display: block;
-  padding: 1.25rem 1.5rem;
-  color: var(--ink-faint, #a8a29e);
-  font-size: .85rem;
-  font-family: var(--font-mono, monospace);
-}
-
-/* ---- Video-stage (YouTube): 16:9 ---- */
-.pcms-embed-stage {
-  position: relative;
-  width: 100%;
-  aspect-ratio: 16 / 9;
-  background: #000;
-}
-.pcms-embed-stage .pcms-embed-mount,
-.pcms-embed-stage iframe {
-  position: absolute;
-  inset: 0;
-  width: 100% !important;
-  height: 100% !important;
-  border: 0;
-}
-.pcms-embed-poster {
-  position: absolute;
-  inset: 0;
-  width: 100%;
-  height: 100%;
-  border: 0;
-  padding: 0;
-  cursor: pointer;
-  background-size: cover;
-  background-position: center;
-  background-color: #000;
-  display: grid;
-  place-items: center;
-  transition: opacity var(--transition, 180ms);
-}
-.pcms-embed-card.is-mounted .pcms-embed-poster { display: none; }
-
-/* ---- Audio-layout (SoundCloud / Spotify-facade) ---- */
-.pcms-embed-audio {
-  display: flex;
-  align-items: stretch;
-  gap: 0;
-  min-height: 96px;
-}
-.pcms-embed-art {
-  flex: 0 0 96px;
-  width: 96px;
-  height: 96px;
-  border: 0;
-  padding: 0;
-  cursor: pointer;
-  background: var(--paper, #faf8f3) center / cover no-repeat;
-  display: grid;
-  place-items: center;
-  color: var(--ink, #1a1a1a);
-}
-.pcms-embed-art.has-art .pcms-embed-bigplay {
-  background: rgba(0,0,0,.45);
-  border-radius: 50%;
-}
-.pcms-embed-info {
-  flex: 1 1 auto;
-  min-width: 0;
-  display: flex;
-  flex-direction: column;
-  justify-content: center;
-  gap: .2rem;
-  padding: .75rem 1rem;
-}
-.pcms-embed-info .pcms-embed-title {
-  font-weight: 600;
-  font-size: .95rem;
-  color: var(--ink, #1a1a1a);
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-.pcms-embed-info .pcms-embed-sub {
-  font-size: .8rem;
-  color: var(--ink-muted, #6b6560);
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-/* SoundCloud-iframe: functioneel maar onzichtbaar (we sturen via de Widget-API
- * en tonen onze eigen balk). Off-screen i.p.v. display:none zodat de widget
- * betrouwbaar initialiseert + audio blijft spelen. */
-.pcms-embed-card--soundcloud .pcms-embed-mount {
-  position: absolute;
-  left: -99999px;
-  top: 0;
-  width: 320px;
-  height: 60px;
-  opacity: 0;
-  pointer-events: none;
-}
-
-/* Spotify: hun iframe IS de speler → tonen. Onze facade (art+info) verdwijnt
- * zodra de controller gemount is. */
-.pcms-embed-card--spotify .pcms-embed-mount { display: block; width: 100%; }
-.pcms-embed-card--spotify .pcms-embed-mount iframe {
-  width: 100% !important;
-  border: 0;
-  display: block;
-}
-.pcms-embed-card--spotify.is-mounted .pcms-embed-art,
-.pcms-embed-card--spotify.is-mounted .pcms-embed-info { display: none; }
-.pcms-embed-card--spotify:not(.is-mounted) .pcms-embed-mount { display: none; }
-
-/* Spotify in een nette witte box (Robin 2026-06-15): witte rand + rounded +
- * schaduw. Hogere specificity (.folio-embed.pcms-embed-card--spotify) zodat dit
- * de generieke .folio-embed.pcms-embed-card-basis (gelijke specificity) wint —
- * staat ook later in het bestand. */
-.folio-embed.pcms-embed-card--spotify {
-  border: 3px solid #ffffff;          /* witte rand blijft */
-  border-radius: 16px;
-  box-shadow: 0 4px 18px rgba(0, 0, 0, .18);
-  /* bg + overflow:hidden erven van de basis-kaart → de binnenste box krijgt
-   * weer de thema-kleur (var(--paper-2)) i.p.v. wit; facade-tekst gebruikt de
-   * gewone thema-tokens en blijft dus leesbaar. */
-}
-
-/* ---- Grote play-knop (poster/art) ---- */
-.pcms-embed-bigplay {
-  width: 64px;
-  height: 64px;
-  display: grid;
-  place-items: center;
-  border-radius: 50%;
-  background: var(--accent, #c2410c);
-  color: #fff;
-  box-shadow: 0 4px 16px rgba(0,0,0,.35);
-  transition: transform var(--transition, 180ms);
-}
-.pcms-embed-art .pcms-embed-bigplay { width: 44px; height: 44px; }
-.pcms-embed-bigplay svg { width: 42%; height: 42%; }
-.pcms-embed-poster:hover .pcms-embed-bigplay,
-.pcms-embed-art:hover .pcms-embed-bigplay { transform: scale(1.08); }
-
-/* ---- Onze controlebalk (YouTube/SoundCloud) ---- */
-.pcms-embed-bar {
-  display: flex;
-  align-items: center;
-  gap: .65rem;
-  padding: .55rem .75rem;
-  border-top: 1px solid var(--rule, #e8e2d5);
-  background: var(--paper, #faf8f3);
-}
-.pcms-embed-pp {
-  flex: 0 0 auto;
-  width: 38px;
-  height: 38px;
-  border: 0;
-  border-radius: 50%;
-  cursor: pointer;
-  background: var(--accent, #c2410c);
-  color: #fff;
-  display: grid;
-  place-items: center;
-}
-.pcms-embed-pp svg { width: 18px; height: 18px; }
-.pcms-embed-cur, .pcms-embed-dur {
-  flex: 0 0 auto;
-  font-size: .75rem;
-  color: var(--ink-muted, #6b6560);
-  font-variant-numeric: tabular-nums;
-  min-width: 2.6em;
-  text-align: center;
-}
-/* Volume: mute-knop + compacte schuif (alleen YT/SC — eigen controls). */
-.pcms-embed-mute {
-  flex: 0 0 auto;
-  width: 28px; height: 28px;
-  border: 0; background: none; padding: 0;
-  color: var(--ink-muted, #6b6560);
-  cursor: pointer;
-  display: grid; place-items: center;
-}
-.pcms-embed-mute svg { width: 18px; height: 18px; }
-.pcms-embed-vol {
-  flex: 0 0 auto;
-  width: 64px;
-  cursor: pointer;
-  accent-color: var(--accent, #c2410c);
-}
-@media (max-width: 480px) {
-  /* Smal scherm: schuif weg, alleen de mute-knop houden (ruimte). */
-  .pcms-embed-vol { display: none; }
-}
-.pcms-embed-seek {
-  position: relative;
-  flex: 1 1 auto;
-  height: 16px;
-  cursor: pointer;
-}
-.pcms-embed-seek::before {
-  content: "";
-  position: absolute;
-  left: 0; right: 0; top: 50%;
-  transform: translateY(-50%);
-  height: 4px;
-  border-radius: 2px;
-  background: var(--rule-2, #d4ccb8);
-}
-.pcms-embed-seek-fill {
-  position: absolute;
-  left: 0; top: 50%;
-  transform: translateY(-50%);
-  height: 4px;
-  width: 0%;
-  border-radius: 2px;
-  background: var(--accent, #c2410c);
-  pointer-events: none;
-  z-index: 1;
-}
-.pcms-embed-badge {
-  flex: 0 0 auto;
-  font-size: .68rem;
-  letter-spacing: .04em;
-  text-transform: uppercase;
-  color: var(--ink-faint, #a8a29e);
-  text-decoration: none;
-}
-.pcms-embed-badge:hover { color: var(--accent, #c2410c); }
-
-/* Provider-accent op de badge */
-.pcms-embed-card--youtube .pcms-embed-badge { color: #ff0033; }
-.pcms-embed-card--soundcloud .pcms-embed-badge { color: #ff5500; }
-
-/* ---- Spotify-frame-badge (geen eigen balk) ---- */
-.pcms-embed-frame-badge {
-  padding: .4rem .75rem;
-  border-top: 1px solid var(--rule, #e8e2d5);
-  background: var(--paper, #faf8f3);
-  font-size: .68rem;
-  letter-spacing: .04em;
-  text-transform: uppercase;
-}
-.pcms-embed-frame-badge a { color: #1db954; text-decoration: none; }
-.pcms-embed-frame-badge a:hover { text-decoration: underline; }
-.pcms-embed-card--spotify.is-mounted .pcms-embed-frame-badge { display: none; }
-
-/* ---- Busy / error ---- */
-.pcms-embed-card.pcms-embed-busy { cursor: progress; }
-.pcms-embed-card.pcms-embed-error .pcms-embed-sub { color: #c0392b; }
-
-.pcms-embed-bigplay,
-.pcms-embed-pp,
-.pcms-embed-poster { -webkit-tap-highlight-color: transparent; }
-
-/* ---- Mobile / touch: plain platform iframe instead of the JS-mounted card.
-   padding-ratio reserves height on every browser (incl. old iOS Safari). ---- */
-.pcms-embed-plain { background: transparent; box-shadow: none; }
-.pcms-embed-ratio { position: relative; width: 100%; padding-bottom: 56.25%; height: 0; }
-.pcms-embed-ratio iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; border-radius: 10px; }
-.pcms-embed-plain-frame { width: 100%; border: 0; display: block; border-radius: 10px; }
-.pcms-embed-plain-link { display: inline-block; padding: .5rem .9rem; }
Index: src/assets/css/guardian.css
===================================================================
--- src/assets/css/guardian.css	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,293 +1,0 @@
-/* Guardian PWA (FEP-633c): its own calm, dark surface; the buoy orange is
-   the accent. Standalone: this page never inherits the site theme. */
-:root {
-  --bg: #141a24;
-  --card: #1e2632;
-  --ink: #e8ecf2;
-  --sub: #93a0b4;
-  --line: #2c3646;
-  --accent: #ff6b35;
-  --ok: #4caf7d;
-  --danger: #e05a5a;   /* alleen voor onomkeerbare stappen, zoals een ward loslaten */
-}
-* { box-sizing: border-box; }
-body {
-  margin: 0;
-  background: var(--bg);
-  color: var(--ink);
-  font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-  padding-bottom: 48px;
-}
-.g-head {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  padding: 16px;
-  background: var(--card);
-  border-bottom: 1px solid var(--line);
-  position: sticky;
-  top: 0;
-  z-index: 2;
-}
-.g-head-txt { flex: 1; }
-.g-head h1 { font-size: 1.15rem; margin: 0; }
-.g-head .g-sub { margin: 0; color: var(--sub); font-size: .78rem; }
-.g-buoy { font-size: 1.7rem; }
-.g-me { color: var(--sub); font-size: .85rem; }
-#site-picker {
-  background: var(--bg); color: var(--ink);
-  border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px;
-}
-main { max-width: 640px; margin: 0 auto; padding: 8px 16px; }
-
-section { padding: 18px 0; border-bottom: 1px solid var(--line); }
-section:last-child { border-bottom: 0; }
-.g-sec-head { display: flex; align-items: center; gap: 8px; }
-.g-sec-head h2 { font-size: 1.02rem; margin: 0; }
-.g-badge {
-  background: var(--accent); color: #fff; font-size: .72rem; font-weight: 700;
-  min-width: 20px; text-align: center; border-radius: 10px; padding: 1px 7px;
-}
-.g-sec-sub { color: var(--sub); font-size: .84rem; margin: 4px 0 12px; }
-.g-empty { color: var(--sub); font-size: .9rem; margin: 6px 0 0; }
-.g-msg { font-size: .85rem; color: var(--ok); margin: 8px 0 0; }
-.g-msg.err { color: #ff7a7a; }
-.g-list { display: flex; flex-direction: column; gap: 8px; }
-
-.g-card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
-.g-card.help { border-left: 3px solid var(--accent); }
-.g-card .row { display: flex; align-items: center; gap: 10px; }
-.g-card .row .grow { flex: 1; min-width: 0; }
-.g-card .who { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.g-card .when { color: var(--sub); font-size: .76rem; white-space: nowrap; }
-.g-card .body { margin-top: 8px; font-size: .92rem; overflow-wrap: anywhere; }
-.g-card .body img { max-width: 100%; border-radius: 8px; margin-top: 6px; }
-.g-link { display: inline-block; margin-top: 8px; color: var(--accent); font-size: .82rem; text-decoration: none; }
-
-.tag { font-size: .72rem; font-weight: 600; padding: 3px 9px; border-radius: 20px; white-space: nowrap; }
-.tag.wait { background: #3a2f1a; color: #e8b04b; }
-.tag.ok { background: #17301f; color: var(--ok); }
-.tag.co { background: #2a1f3a; color: #c39bff; }
-
-#adopt-form { display: flex; gap: 8px; }
-#adopt-form input {
-  flex: 1; min-width: 0;
-  background: var(--bg); color: var(--ink);
-  border: 1px solid var(--line); border-radius: 10px; padding: 11px 12px;
-}
-button {
-  background: var(--accent); color: #fff; border: 0;
-  border-radius: 10px; padding: 11px 18px; font-weight: 600; cursor: pointer;
-}
-button:disabled { opacity: .5; cursor: default; }
-button.quiet { background: transparent; color: var(--sub); border: 1px solid var(--line); font-weight: 500; }
-button.quiet.is-on { color: var(--ok); border-color: var(--ok); }
-button.small { padding: 6px 12px; font-size: .82rem; }
-
-/* Wards' corner (v2 meekijken) */
-.g-card.feed .g-when { color: var(--muted, #888); font-size: .8em; margin-left: auto; }
-.g-card.feed .feed-body { margin-top: 6px; line-height: 1.4; }
-.g-card.feed .feed-body img { max-width: 100%; border-radius: 8px; }
-
-/* ── The body of a post ────────────────────────────────────────────────────
-   The 🛟 card shows a real post, so it is rendered by the same partial de
-   Krant and Berichten use (partials/note-body). These are that partial's
-   classes, in the PWA's own colours: the dashboard is standalone and does not
-   load the site's stylesheet. */
-.g-note .tl-content { line-height: 1.5; overflow-wrap: anywhere; }
-.g-note .tl-content p { margin: .35rem 0; }
-.g-note .tl-content p:first-child { margin-top: 0; }
-.g-note .tl-content p:last-child { margin-bottom: 0; }
-.g-note .tl-content a { color: var(--accent); }
-.g-note img.emoji { height: 1.25em; width: auto; margin: 0 .05em; vertical-align: -.2em;
-  display: inline-block; border-radius: 0; background: none; }
-
-/* Quote card and link preview: one card, two origins. */
-.g-note .tl-quote { margin: 8px 0 0; padding: 8px 10px; border-radius: 10px;
-  border: 1px solid var(--line); background: var(--card-2, rgba(128,128,128,.07)); }
-.g-note .tl-quote-head { display: flex; align-items: center; gap: 6px; margin: 0 0 4px; min-width: 0; }
-.g-note .tl-quote-avatar { flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
-.g-note .tl-quote-name { font-weight: 600; font-size: .82rem; white-space: nowrap; }
-.g-note .tl-quote-handle { color: var(--sub); font-size: .76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
-.g-note .tl-quote-body { color: var(--sub); font-size: .88rem; line-height: 1.45; max-height: 14em; overflow: hidden; }
-.g-note .tl-quote-media { display: block; margin: 6px 0 0; }
-
-/* The capture a 🛟 usually carries. */
-.g-note .tl-media { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 0; }
-.g-note .tl-media-img { display: block; border-radius: 10px; overflow: hidden; }
-.g-note .tl-media-img img { width: 100%; height: auto; display: block; background: #fff; }
-.g-note .tl-media-video, .g-note .tl-media-audio { width: 100%; margin: 8px 0 0; border-radius: 10px; display: block; }
-.g-note .tl-media-video { max-height: 320px; background: #000; }
-
-/* ── Paneel per ward ───────────────────────────────────────────────────────
-   Een guardian denkt per kind, niet per functie: achter de regel van een kind
-   zit alles over dat kind. De regel zelf draagt de tellers, zodat een dicht
-   paneel nooit iets verbergt dat een antwoord nodig heeft. */
-.g-card.ward > .row { gap: 8px; }
-.g-panel { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line); }
-/* HIDDEN MOET ALTIJD WINNEN, van elke display-regel hierboven of hieronder.
-   Dit stond eerst alleen op .g-panel, en toen de poortenlijst `display: grid`
-   kreeg klapte die niet meer dicht -- Barts melding (9-8): "die is er onderuit
-   gekomen". Ook het geschiedenislogje deelde die klasse en stond dus permanent
-   open.
-
-   Een auteursregel slaat de browserstijl `[hidden] { display: none }` ongeacht
-   specificiteit, dus elke nieuwe `display:` in dit bestand zou de fout opnieuw
-   maken. Dit is de zeldzame plek waar !important het juiste gereedschap is: de
-   regel hoort per definitie boven alles te staan. */
-[hidden] { display: none !important; }
-.g-panel-sec + .g-panel-sec { margin-top: 16px; }
-.g-panel-sec h3 { margin: 0 0 6px; font-size: .82rem; font-weight: 600; letter-spacing: .03em;
-  text-transform: uppercase; color: var(--sub); }
-.g-panel-sec .g-card { background: transparent; border-color: var(--line); }
-.g-panel-sec .g-card + .g-card { margin-top: 6px; }
-.g-panel-sec .row { flex-wrap: wrap; }
-.g-empty.small { margin: 0; font-size: .85rem; }
-.tag.help { color: var(--accent); border-color: var(--accent); }
-
-/* Loslaten van een ward: geen browser-confirm maar een stap in het paneel, met
-   de gevolgen erbij. Rustig van kleur; het is een besluit, geen alarm. */
-.g-warn { margin-top: 10px; padding: 12px 14px; border-radius: 10px;
-  border: 1px solid var(--danger); background: color-mix(in srgb, var(--danger) 8%, transparent); }
-.g-warn strong { display: block; margin-bottom: 6px; }
-.g-warn p { margin: 0 0 8px; font-size: .88rem; line-height: 1.45; color: var(--sub); }
-.g-warn p.grave { color: var(--ink); font-weight: 500; }
-.g-warn .row { margin-top: 10px; gap: 8px; }
-button.danger { background: var(--danger); color: #fff; border-color: var(--danger); }
-
-/* ── Beschikbaarheid (FEP-633c 3.6) ────────────────────────────────────────
-   De buddy-list-stip op de verantwoordelijkheids-as: groen beschikbaar, geel
-   afwezig-verklaard met een einde, grijs slapend-geconstateerd (een antwoord
-   herstelt alles). Labels zijn UI; de draad houdt de spec-termen. */
-.g-avdot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex: 0 0 auto; }
-.g-avdot.is-active { background: var(--ok); }
-.g-avdot.is-away { background: #e0a83a; }
-.g-avdot.is-dormant { background: #8a93a3; }
-.g-avlabel { color: var(--sub); font-size: .8rem; white-space: nowrap; }
-.row.g-guard { gap: 8px; margin-top: 6px; }
-/* The status of a proposal this guardian sent (5.6): one quiet line per
-   feature. The colour says the state before the words do. */
-.g-prop { margin: 6px 0 0; color: var(--sub); }
-.g-prop-accepted { color: var(--ok); }
-.g-prop-rejected, .g-prop-expired { color: var(--danger); }
-.g-away-row { display: flex; gap: 8px; }
-.g-card.lapse { border-left: 3px solid #8a93a3; }
-.g-card.lapse .row { margin-top: 8px; gap: 8px; }
-
-/* Een voorgestelde gated setting (FEP-633c 5.6) van een mede-guardian. */
-.g-card.gated { border-left: 3px solid var(--accent); }
-.g-card.gated .row { margin-top: 8px; gap: 8px; }
-
-/* Het gate-paneel per ward (shaer-ahy.1). Een rij per gate, met het soort en de
-   drempel erbij. Bewust rustig: dit is een overzicht om te LEZEN, en pas daarna
-   een plek om iets voor te stellen. */
-.g-gate { padding: .5rem 0; border-top: 1px solid var(--g-line, rgba(0,0,0,.08)); }
-.g-gate:first-of-type { border-top: 0; }
-.g-gate-head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; }
-.g-gate-name { font-weight: 600; }
-/* Het soort is een fluistering, geen kop: het hoort erbij te staan zonder de
-   naam van de gate te overstemmen. */
-.g-gate-kind { font-size: .78em; opacity: .65; }
-.g-gate-meta { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: .15rem; }
-.g-gate-meta .g-dim { opacity: .55; }
-/* Onomkeerbaar krijgt gewicht, want dat is het enige in dit paneel dat je niet
-   kunt terugdraaien. */
-.g-gate-warn { font-weight: 600; }
-.g-gate-wait { font-weight: 600; }
-
-/* De gedeelde staat van een hulpvraag (shaer-lgo). Rustig: dit is geen alarm,
-   het moet alleen onmogelijk te missen zijn wie er al op af is. */
-.g-help-state { margin-top: .5rem; }
-.g-help-pick { font-size: .9em; }
-.g-help-age { opacity: .6; }        /* oud, maar niet weg */
-.g-help-done { font-weight: 600; }
-.g-confirm { margin-top: .5rem; }
-
-/* Het archief van afgehandelde hulpvragen: aanwezig, niet in de weg. */
-.g-help-archive { margin-top: .5rem; opacity: .75; }
-
-/* De geschiedenis onderaan een wardpaneel: aanwezig, duidelijk afgesloten, en
-   visueel losgemaakt van wat nog wacht. */
-.g-panel-history { border-top: 2px solid var(--g-line, rgba(0,0,0,.12)); margin-top: 1rem; opacity: .8; }
-
-/* ── Het poortenpaneel als cockpit (shaer-ahy.1, Barts richting 8-8) ──
-   Elke gate is een toets zoals op een aircopaneel: lampje links, naam erop,
-   en een klein verzonken statusveld met de stand als tekst. Het lampje en het
-   veld zeggen hetzelfde in twee vormen -- kleur alleen is geen antwoord. */
-.g-gates { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .5rem; margin: .35rem 0; }
-.g-sw-wrap { display: flex; flex-direction: column; gap: .25rem; min-width: 0; }
-.g-sw {
-  display: flex; flex-direction: column; align-items: stretch; gap: .3rem;
-  text-align: left; padding: .5rem .6rem; border-radius: 10px;
-  border: 1px solid var(--g-line, rgba(0,0,0,.18));
-  background: var(--g-key, linear-gradient(180deg, rgba(255,255,255,.9), rgba(0,0,0,.04)));
-  box-shadow: inset 0 1px 0 rgba(255,255,255,.6), 0 1px 2px rgba(0,0,0,.12);
-  cursor: pointer; font: inherit;
-  /* Expliciet, niet geerfd (Barts aanwijzing, 8-8): de paginakleur zakte weg
-     in het toetsvlak. Het opschrift is het donkerste op de toets. */
-  color: var(--g-key-ink, #1b1e22);
-}
-.g-sw:active:not([disabled]) { box-shadow: inset 0 1px 3px rgba(0,0,0,.25); transform: translateY(1px); }
-/* Niet verstelbaar is niet kapot: de toets drukt niet in, de stand blijft leesbaar. */
-.g-sw[disabled] { cursor: default; opacity: .8; }
-.g-sw-head { display: flex; align-items: center; gap: .45em; min-width: 0; }
-.g-sw-name { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-
-/* Het lampje. Aan gloeit, uit is dof, onbeslist is een open kringetje (geen
-   besluit is de afwezigheid van een), wachten knippert -- de enige stand die
-   vanzelf verandert, dus de enige die om je blik mag vragen -- en gepland is
-   een lege fitting: een poort die nog niet bestaat is geen dichte deur. */
-.g-sw-lamp { flex: none; width: .6em; height: .6em; border-radius: 50%; box-shadow: inset 0 0 2px rgba(0,0,0,.35); }
-.g-lamp-on { background: var(--g-lamp-on, #2f9e5f); box-shadow: 0 0 6px 1px rgba(47,158,95,.55); }
-.g-lamp-off { background: var(--g-lamp-off, #7a8087); }
-.g-lamp-undecided { background: transparent; border: 1px dashed currentColor; opacity: .55; box-shadow: none; }
-.g-lamp-waiting { background: var(--g-warn, #b8860b); animation: g-lamp-pulse 1.4s ease-in-out infinite; }
-.g-lamp-planned { background: transparent; border: 1px dotted currentColor; opacity: .4; box-shadow: none; }
-@keyframes g-lamp-pulse { 50% { opacity: .3; } }
-@media (prefers-reduced-motion: reduce) { .g-lamp-waiting { animation: none; } }
-
-/* In het donker geen lichte toetsen: de site heeft een echt donker thema
-   (style.css), en een wit-op-lichtgrijs paneel is daar onleesbaar. Zelfde
-   vorm, andere verf; de vars blijven overschrijfbaar. */
-@media (prefers-color-scheme: dark) {
-  .g-sw {
-    background: var(--g-key, linear-gradient(180deg, rgba(255,255,255,.10), rgba(0,0,0,.25)));
-    box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 1px 2px rgba(0,0,0,.5);
-    color: var(--g-key-ink, rgba(255,255,255,.95));
-  }
-  .g-sw-state { background: var(--g-lcd, rgba(0,0,0,.4)); }
-}
-
-/* Het statusveld: verzonken venstertje, vaste letter, zoals de temperatuur op
-   het aircopaneel. */
-.g-sw-state {
-  align-self: flex-start; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
-  font-size: .74em; padding: .1em .5em; border-radius: 4px;
-  border: 1px solid var(--g-line, rgba(0,0,0,.18));
-  background: var(--g-lcd, rgba(0,0,0,.07));
-  box-shadow: inset 0 1px 2px rgba(0,0,0,.12);
-  /* Het oplichtende display van de toets: de stand in de accentkleur van het
-     huis (Barts aanwijzing, 8-8), zoals de cijfers op het aircopaneel. */
-  color: var(--accent, #6b8f71);
-  font-weight: 600;
-}
-.g-sw-meta { display: flex; flex-wrap: wrap; gap: .3em .7em; opacity: .7; font-size: .82em; }
-
-/* De poortenlijst blijft ingeklapt tot je hem opent. Met twaalf toetsen duwt
-   een open paneel alles eronder van het scherm. */
-.g-gates-toggle { display: block; width: 100%; text-align: left; margin: .25rem 0; }
-
-/* De waarschuwing bij een gate-voorstel (shaer-nf9). Geen rode paniekbalk: dit
-   moet gelezen worden, niet weggeklikt. Een rand links, zoals een citaat. */
-.g-warn { margin: .5rem 0; padding: .4rem 0 .4rem .7rem; border-left: 3px solid var(--g-warn, #b8860b); }
-.g-warn p { margin: 0 0 .25rem; }
-.g-warn p:last-child { margin-bottom: 0; }
-.g-confirm .g-warn { border-left: 0; padding-left: 0; }
-
-/* De vraag van het kind zelf (shaer-8ru). Het enige op een poortrij dat van het
-   kind komt, dus niet grijs weggeschreven tussen de drempels. */
-.g-gate-asked { margin: .2rem 0 0; color: var(--g-asked, #7a5ea8); font-weight: 600; }
-
-/* "Jouw antwoord beslist dit" (shaer-8vt). Zwaarder dan de rest van de
-   waarschuwing: dit is het verschil tussen meedoen en beslissen. */
-.g-decisive { font-weight: 700; color: var(--g-decisive, #a33); }
Index: src/assets/css/reply-editor.css
===================================================================
--- src/assets/css/reply-editor.css	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,120 +1,0 @@
-/* Rich reply editor (partials/reply-editor.ejs + js/reply-editor.js).
-   Styled as a small sibling of the post editor (pe-*): same 32px icon buttons,
-   same accent-active state, same framed feel, scaled down for a reply. */
-
-/* !important: site CSS gives forms inside comment threads `display: contents`,
-   which collapses the form to a 0x0 non-box — flex layout AND the fixed
-   full-screen overlay silently break. This component must own its box. */
-.re-form[data-re] { display: flex !important; flex-direction: column; gap: .5rem; }
-
-/* ── Toolbar (matches .pe-toolbar) ─────────────────────────────────────── */
-.re-toolbar { display: flex; align-items: center; gap: .1rem; flex-wrap: wrap; }
-.re-toolbar button {
-  display: inline-flex; align-items: center; justify-content: center;
-  width: 32px; height: 32px; padding: 0;
-  background: transparent; border: 1px solid transparent; border-radius: 6px;
-  color: var(--ink, currentColor); cursor: pointer;
-  touch-action: manipulation; -webkit-user-select: none; user-select: none;
-  -webkit-tap-highlight-color: transparent;
-  transition: background .15s ease, color .15s ease, border-color .15s ease;
-}
-.re-toolbar button svg { width: 16px; height: 16px; }
-@media (hover: hover) {
-  .re-toolbar button:hover { background: var(--paper-2, color-mix(in srgb, var(--ink, #000) 8%, transparent)); color: var(--accent, #06c); }
-}
-.re-toolbar button:active { transform: scale(.94); }
-.re-toolbar button.is-active { background: var(--accent, #06c); color: #fff; border-color: var(--accent, #06c); }
-.re-toolbar button.is-active svg { color: #fff; }
-.re-toolbar-sep { width: 1px; height: 18px; background: var(--rule, color-mix(in srgb, var(--ink, #000) 16%, transparent)); margin: 0 .3rem; flex-shrink: 0; }
-
-/* ── Editor box (matches .pe-editor) ───────────────────────────────────── */
-.re-editor {
-  min-height: 4rem; padding: .6rem .75rem; border-radius: 10px;
-  border: 1px solid var(--rule, color-mix(in srgb, var(--ink, #000) 16%, transparent));
-  background: var(--paper, transparent); color: inherit;
-  overflow-wrap: anywhere; outline: none; line-height: 1.5;
-  transition: border-color .15s ease, box-shadow .15s ease;
-}
-.re-editor:focus {
-  border-color: var(--accent, #06c);
-  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #06c) 18%, transparent);
-}
-.re-editor:empty::before { content: attr(data-ph); color: color-mix(in srgb, var(--ink, #000) 40%, transparent); pointer-events: none; }
-.re-editor p { margin: 0 0 .5rem; }
-.re-editor p:last-child { margin-bottom: 0; }
-.re-editor ul, .re-editor ol { margin: .4rem 0; padding-left: 1.4rem; }
-.re-editor blockquote {
-  margin: .4rem 0; padding: .1rem 0 .1rem .75rem;
-  border-left: 3px solid color-mix(in srgb, var(--ink, #000) 22%, transparent);
-  color: color-mix(in srgb, var(--ink, #000) 78%, transparent);
-}
-.re-editor a { color: var(--accent, #06c); }
-.re-editor.re-drop { border-color: var(--accent, #06c); border-style: dashed; background: color-mix(in srgb, var(--accent, #06c) 6%, transparent); }
-
-/* ── Footer: language + send ───────────────────────────────────────────── */
-.re-foot { display: flex; align-items: center; gap: .6rem; }
-.re-foot .re-send { margin-left: auto; }
-.re-lang {
-  font: inherit; font-size: .82rem; padding: .32rem .5rem; border-radius: 8px;
-  border: 1px solid var(--rule, color-mix(in srgb, var(--ink, #000) 16%, transparent));
-  background: transparent; color: inherit; max-width: 11rem;
-}
-
-/* ── Mentions bar (u02): removable partner chips ───────────────────────── */
-.re-mentions { display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; }
-.re-mentions-label { font-size: .76rem; text-transform: uppercase; letter-spacing: .03em; color: color-mix(in srgb, var(--ink, #000) 50%, transparent); }
-.re-mention {
-  display: inline-flex; align-items: center; gap: .1rem;
-  padding: .12rem .2rem .12rem .5rem; border-radius: 999px; font-size: .78rem; line-height: 1.5;
-  background: color-mix(in srgb, var(--accent, #06c) 12%, transparent);
-  border: 1px solid color-mix(in srgb, var(--accent, #06c) 28%, transparent);
-  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
-}
-.re-mention-del { display: inline-flex; align-items: center; justify-content: center; width: 1.2rem; height: 1.2rem; border: none; background: none; color: inherit; opacity: .65; font-size: 1rem; line-height: 1; cursor: pointer; }
-.re-mention-del:hover { opacity: 1; }
-
-/* ── Media attachment chips (while composing) ──────────────────────────── */
-.re-attachments { display: flex; flex-wrap: wrap; gap: .4rem; }
-.re-att {
-  display: inline-flex; align-items: center; gap: .4rem;
-  padding: .25rem .3rem .25rem .5rem; border-radius: 10px; font-size: .78rem;
-  background: var(--paper-2, color-mix(in srgb, var(--ink, #000) 7%, transparent));
-  border: 1px solid var(--rule, color-mix(in srgb, var(--ink, #000) 12%, transparent));
-  max-width: 100%; overflow: hidden;
-}
-.re-att img { width: 40px; height: 40px; object-fit: cover; border-radius: 7px; display: block; }
-.re-att-del { display: inline-flex; align-items: center; justify-content: center; width: 1.3rem; height: 1.3rem; border: none; background: none; color: inherit; opacity: .65; font-size: 1.05rem; line-height: 1; cursor: pointer; }
-.re-att-del:hover { opacity: 1; }
-.re-att-busy { opacity: .7; }
-.re-att-err { background: color-mix(in srgb, #c00 16%, transparent); border-color: color-mix(in srgb, #c00 35%, transparent); }
-
-/* ── Media on a sent reply in the thread (fedi-node) ───────────────────── */
-.re-reply-media { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
-.re-reply-media img { max-width: min(260px, 100%); max-height: 260px; border-radius: 12px; display: block; }
-.re-reply-media audio { max-width: 100%; }
-.re-reply-media video { max-width: min(320px, 100%); border-radius: 12px; }
-
-/* ── Full-screen compose (mobile). JS toggles .re-full on the form. ────── */
-.re-head { display: flex; align-items: center; gap: .6rem; padding-bottom: .2rem; border-bottom: 1px solid var(--rule, color-mix(in srgb, var(--ink, #000) 12%, transparent)); }
-.re-head-title { flex: 1; text-align: center; font-size: .95rem; font-weight: 600; }
-.re-cancel { display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; border: none; background: none; color: inherit; font-size: 1.5rem; line-height: 1; cursor: pointer; border-radius: 8px; }
-@media (hover: hover) { .re-cancel:hover { background: var(--paper-2, color-mix(in srgb, var(--ink, #000) 8%, transparent)); } }
-
-.re-form.re-full {
-  /* Above the bottom tab bar (1050) and the profile/audio sheets (1100). */
-  position: fixed; inset: 0; z-index: 1200;
-  background: var(--paper, #fff); padding: .8rem .9rem; gap: .55rem;
-  padding-top: calc(.8rem + env(safe-area-inset-top));
-  padding-bottom: calc(.8rem + env(safe-area-inset-bottom));
-}
-.re-form.re-full .re-head { order: -1; }   /* the top bar stays topmost, above the mentions bar */
-.re-form.re-full .re-editor { flex: 1; min-height: 0; overflow-y: auto; border: none; box-shadow: none; padding: .4rem .1rem; }
-.re-form.re-full .re-editor:focus { border: none; box-shadow: none; }
-.re-form.re-full .re-send { display: none; }         /* send lives in the top bar */
-/* ... EN ANDERSOM. Die regel hierboven stond er wel, deze niet: buiten
-   fullscreen bleven allebei de knoppen staan, dus twee keer Verzenden onder
-   elkaar. .re-send-top had helemaal geen CSS -- hij werd nooit verborgen.
-   Een knop hoort in de bovenbalk OF in de voet, nooit in allebei. */
-.re-form:not(.re-full) .re-send-top { display: none; }
-.re-form.re-full .re-foot { justify-content: flex-start; }
-html.re-lock, html.re-lock body { overflow: hidden; }
Index: src/assets/css/style.css
===================================================================
--- src/assets/css/style.css	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/assets/css/style.css	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -54,5 +54,5 @@
 }
 
-/* Klonkt publieke stijl — editorial-ish, geen emoji-UI */
+/* PrutCMS publieke stijl — editorial-ish, geen emoji-UI */
 
 :root {
@@ -63,23 +63,6 @@
     --radius: 8px; --transition: 180ms cubic-bezier(.4,0,.2,1);
     --safe-top: env(safe-area-inset-top, 0); --safe-bot: env(safe-area-inset-bottom, 0);
-    /* iOS top inset: starts from env(), but a tiny boot script overrides it with a
-       real/measured value (env() resolves to 0 in some iOS standalone states). */
-    --ios-safe-top: env(safe-area-inset-top, 0px);
     color-scheme: light dark;
 }
-
-/* Native controls (datum/tijd-picker, scrollbars) het SITE-thema laten volgen i.p.v.
-   de OS-voorkeur, en de datum/tijd-velden de site-accentkleur geven. Zo staat de
-   ingebouwde kalender-popup in dark + accent (de popup zelf is een browser-widget,
-   dus fonts/randen blijven van de browser). */
-[data-theme="dark"] { color-scheme: dark; }
-[data-theme="light"] { color-scheme: light; }
-input[type="date"], input[type="time"], input[type="datetime-local"] { accent-color: var(--accent, #6b8f71); }
-input[type="date"]::-webkit-calendar-picker-indicator,
-input[type="time"]::-webkit-calendar-picker-indicator,
-input[type="datetime-local"]::-webkit-calendar-picker-indicator { cursor: pointer; opacity: .7; }
-input[type="date"]:hover::-webkit-calendar-picker-indicator,
-input[type="time"]:hover::-webkit-calendar-picker-indicator,
-input[type="datetime-local"]:hover::-webkit-calendar-picker-indicator { opacity: 1; }
 
 /* ============================================================
@@ -112,23 +95,4 @@
 }
 
-/* ── KLONKT ── merk-standaard: clean wit → bijna-zwart (neutraal, gouden accent) ── */
-[data-palette="klonkt"] {
-    --paper: #ffffff; --paper-2: #f4f4f5;
-    --ink: #09090b; --ink-soft: #27272a; --ink-muted: #52525b; --ink-faint: #a1a1aa;
-    --rule: #e4e4e7; --rule-2: #d4d4d8;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="klonkt"]:not([data-theme="light"]) {
-        --paper: #0a0a0a; --paper-2: #18181b;
-        --ink: #fafafa; --ink-soft: #d4d4d8; --ink-muted: #a1a1aa; --ink-faint: #71717a;
-        --rule: #27272a; --rule-2: #3f3f46;
-    }
-}
-[data-theme="dark"][data-palette="klonkt"] {
-    --paper: #0a0a0a; --paper-2: #18181b;
-    --ink: #fafafa; --ink-soft: #d4d4d8; --ink-muted: #a1a1aa; --ink-faint: #71717a;
-    --rule: #27272a; --rule-2: #3f3f46;
-}
-
 /* ── PAPER ── minimalistisch wit → zwart ── */
 [data-palette="paper"] {
@@ -150,4 +114,22 @@
 }
 
+/* ── OCEAN ── koel blauw-grijs → diep navy ── */
+[data-palette="ocean"] {
+    --paper: #f0f6fb; --paper-2: #dce9f4;
+    --ink: #0f1e2e; --ink-soft: #1e3a52; --ink-muted: #475569; --ink-faint: #94a3b8;
+    --rule: #c7d9ea; --rule-2: #a8c0d6;
+}
+@media (prefers-color-scheme: dark) {
+    [data-palette="ocean"]:not([data-theme="light"]) {
+        --paper: #0a1929; --paper-2: #152a3f;
+        --ink: #e0f2fe; --ink-soft: #bfdbfe; --ink-muted: #93c5fd; --ink-faint: #64748b;
+        --rule: #1e3a52; --rule-2: #2a4a68;
+    }
+}
+[data-theme="dark"][data-palette="ocean"] {
+    --paper: #0a1929; --paper-2: #152a3f;
+    --ink: #e0f2fe; --ink-soft: #bfdbfe; --ink-muted: #93c5fd; --ink-faint: #64748b;
+    --rule: #1e3a52; --rule-2: #2a4a68;
+}
 
 /* ── FOREST ── olijfgroen → donker bos ── */
@@ -227,155 +209,21 @@
 }
 
-/* ── SAND ── warm papier (subtiel, niet fel geel) → warm donker ── (key blijft 'cream') */
+/* ── CREAM ── warm papier → caramel-donker ── */
 [data-palette="cream"] {
-    --paper: #f7f3ec; --paper-2: #ece5d8;
-    --ink: #232019; --ink-soft: #43392c; --ink-muted: #6f6149; --ink-faint: #a3957c;
-    --rule: #ddd3c0; --rule-2: #c9bca3;
+    --paper: #fefaf0; --paper-2: #f5ebd5;
+    --ink: #2a1f0f; --ink-soft: #4a3820; --ink-muted: #786140; --ink-faint: #ad9068;
+    --rule: #e8d9b0; --rule-2: #d1bc8a;
 }
 @media (prefers-color-scheme: dark) {
     [data-palette="cream"]:not([data-theme="light"]) {
-        --paper: #14120d; --paper-2: #221e15;
-        --ink: #efe9dd; --ink-soft: #d0c6b2; --ink-muted: #a3957c; --ink-faint: #6f6149;
-        --rule: #322c20; --rule-2: #463d2c;
+        --paper: #1a1208; --paper-2: #2e2012;
+        --ink: #fef3d6; --ink-soft: #e0cfa0; --ink-muted: #b0996b; --ink-faint: #7a6848;
+        --rule: #3d2c16; --rule-2: #523d1f;
     }
 }
 [data-theme="dark"][data-palette="cream"] {
-    --paper: #14120d; --paper-2: #221e15;
-    --ink: #efe9dd; --ink-soft: #d0c6b2; --ink-muted: #a3957c; --ink-faint: #6f6149;
-    --rule: #322c20; --rule-2: #463d2c;
-}
-
-/* ── OCEAN ── helder blauw → diep oceaanblauw ── */
-[data-palette="ocean"] {
-    --paper: #eef4fb; --paper-2: #d8e6f7;
-    --ink: #0f2942; --ink-soft: #1d4368; --ink-muted: #4a6e90; --ink-faint: #87a3c0;
-    --rule: #c2d8ee; --rule-2: #a3c2e0;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="ocean"]:not([data-theme="light"]) {
-        --paper: #081726; --paper-2: #102a42;
-        --ink: #d6e8fb; --ink-soft: #aecbe8; --ink-muted: #7e9fc0; --ink-faint: #547390;
-        --rule: #1d3a56; --rule-2: #2a4d6e;
-    }
-}
-[data-theme="dark"][data-palette="ocean"] {
-    --paper: #081726; --paper-2: #102a42;
-    --ink: #d6e8fb; --ink-soft: #aecbe8; --ink-muted: #7e9fc0; --ink-faint: #547390;
-    --rule: #1d3a56; --rule-2: #2a4d6e;
-}
-
-/* ── TEAL ── mint-turquoise → diep teal ── */
-[data-palette="teal"] {
-    --paper: #ecf7f5; --paper-2: #cfe9e4;
-    --ink: #0c2e2a; --ink-soft: #194a44; --ink-muted: #467168; --ink-faint: #84aaa2;
-    --rule: #bce0d9; --rule-2: #9bcbc1;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="teal"]:not([data-theme="light"]) {
-        --paper: #06201d; --paper-2: #0e332e;
-        --ink: #d4f2ec; --ink-soft: #aad9d0; --ink-muted: #7aaaa0; --ink-faint: #517a72;
-        --rule: #1a4a42; --rule-2: #275e54;
-    }
-}
-[data-theme="dark"][data-palette="teal"] {
-    --paper: #06201d; --paper-2: #0e332e;
-    --ink: #d4f2ec; --ink-soft: #aad9d0; --ink-muted: #7aaaa0; --ink-faint: #517a72;
-    --rule: #1a4a42; --rule-2: #275e54;
-}
-
-/* ── CANDY ── zacht roze-rood → diep kersenrood ── */
-[data-palette="candy"] {
-    --paper: #fdf1f3; --paper-2: #f8d8de;
-    --ink: #3a1018; --ink-soft: #5e1f2a; --ink-muted: #8e4450; --ink-faint: #c2868f;
-    --rule: #f0c5cd; --rule-2: #e3a0ab;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="candy"]:not([data-theme="light"]) {
-        --paper: #220810; --paper-2: #3a121d;
-        --ink: #fde0e6; --ink-soft: #f5b8c4; --ink-muted: #cd8b98; --ink-faint: #8e5f6a;
-        --rule: #4d1a28; --rule-2: #632536;
-    }
-}
-[data-theme="dark"][data-palette="candy"] {
-    --paper: #220810; --paper-2: #3a121d;
-    --ink: #fde0e6; --ink-soft: #f5b8c4; --ink-muted: #cd8b98; --ink-faint: #8e5f6a;
-    --rule: #4d1a28; --rule-2: #632536;
-}
-
-/* ── AMBER ── warm honing → diep amber ── */
-[data-palette="amber"] {
-    --paper: #fdf6e9; --paper-2: #f7e6c2;
-    --ink: #3a2a0c; --ink-soft: #5e451a; --ink-muted: #8e7340; --ink-faint: #c2a877;
-    --rule: #f0dca8; --rule-2: #e3c581;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="amber"]:not([data-theme="light"]) {
-        --paper: #221a08; --paper-2: #3a2c12;
-        --ink: #fdeecb; --ink-soft: #f0d49a; --ink-muted: #c2a877; --ink-faint: #8e7340;
-        --rule: #4d3a16; --rule-2: #634d20;
-    }
-}
-[data-theme="dark"][data-palette="amber"] {
-    --paper: #221a08; --paper-2: #3a2c12;
-    --ink: #fdeecb; --ink-soft: #f0d49a; --ink-muted: #c2a877; --ink-faint: #8e7340;
-    --rule: #4d3a16; --rule-2: #634d20;
-}
-
-/* ── ROSE ── zacht roze → wijnrood ── */
-[data-palette="rose"] {
-    --paper: #fdf2f4; --paper-2: #f8dde3;
-    --ink: #2e1419; --ink-soft: #4d242c; --ink-muted: #7a4651; --ink-faint: #b3838d;
-    --rule: #f0c9d1; --rule-2: #e0a3af;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="rose"]:not([data-theme="light"]) {
-        --paper: #1f0a0f; --paper-2: #33141c;
-        --ink: #fce4ea; --ink-soft: #f0bcc8; --ink-muted: #c98f9d; --ink-faint: #8a6470;
-        --rule: #4a1f2c; --rule-2: #5e2d3a;
-    }
-}
-[data-theme="dark"][data-palette="rose"] {
-    --paper: #1f0a0f; --paper-2: #33141c;
-    --ink: #fce4ea; --ink-soft: #f0bcc8; --ink-muted: #c98f9d; --ink-faint: #8a6470;
-    --rule: #4a1f2c; --rule-2: #5e2d3a;
-}
-
-
-/* ── TERRACOTTA ── warme klei → diep brick ── (key blijft 'mint' voor DB-compat) */
-[data-palette="mint"] {
-    --paper: #faf2ee; --paper-2: #f2ddd0;
-    --ink: #2e1a12; --ink-soft: #4a2c1e; --ink-muted: #7a5544; --ink-faint: #b3917e;
-    --rule: #eccdbb; --rule-2: #ddb49d;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="mint"]:not([data-theme="light"]) {
-        --paper: #1f120c; --paper-2: #2e1c13;
-        --ink: #f7e6da; --ink-soft: #e0c3b0; --ink-muted: #b3917c; --ink-faint: #7a5544;
-        --rule: #4a2c1e; --rule-2: #5e3a28;
-    }
-}
-[data-theme="dark"][data-palette="mint"] {
-    --paper: #1f120c; --paper-2: #2e1c13;
-    --ink: #f7e6da; --ink-soft: #e0c3b0; --ink-muted: #b3917c; --ink-faint: #7a5544;
-    --rule: #4a2c1e; --rule-2: #5e3a28;
-}
-
-/* ── LILAC ── zacht lila → donker violet ── */
-[data-palette="lilac"] {
-    --paper: #faf4fb; --paper-2: #f0e0f3;
-    --ink: #2a1830; --ink-soft: #46294f; --ink-muted: #7a5685; --ink-faint: #b393bd;
-    --rule: #e6cdec; --rule-2: #d4abdd;
-}
-@media (prefers-color-scheme: dark) {
-    [data-palette="lilac"]:not([data-theme="light"]) {
-        --paper: #170a1c; --paper-2: #281433;
-        --ink: #f3e2f7; --ink-soft: #dabce4; --ink-muted: #b088c0; --ink-faint: #7a5a88;
-        --rule: #3d1f4a; --rule-2: #4f2b5e;
-    }
-}
-[data-theme="dark"][data-palette="lilac"] {
-    --paper: #170a1c; --paper-2: #281433;
-    --ink: #f3e2f7; --ink-soft: #dabce4; --ink-muted: #b088c0; --ink-faint: #7a5a88;
-    --rule: #3d1f4a; --rule-2: #4f2b5e;
+    --paper: #1a1208; --paper-2: #2e2012;
+    --ink: #fef3d6; --ink-soft: #e0cfa0; --ink-muted: #b0996b; --ink-faint: #7a6848;
+    --rule: #3d2c16; --rule-2: #523d1f;
 }
 
@@ -385,41 +233,9 @@
 
 * { box-sizing: border-box; }
-html {
-    -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: transparent;
-    overscroll-behavior-y: none;
-    /* Horizontaal clippen op ROOT-niveau: dit propageert naar de viewport (geen
-       horizontale scroll) ZONDER position:fixed te breken — html is sowieso de
-       document-scroller. De body mag GEEN overflow-x krijgen, want dan zou die een
-       tweede scroll-container worden en zou de fixed bottom-tab meeschuiven. */
-    overflow-x: hidden;
-}
+html { -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; overscroll-behavior-y: none; }
 body {
     margin: 0;
-}
-
-/* ── Thema-scrollbar: slank, afgerond, subtiel — vervangt de standaard chunky
-   browser-scrollbar (met pijltjes) die niet bij het donkere thema past.
-   Werkt op de pagina én op interne scrollers (queue, chat-thread, dropdowns). ── */
-* {
-    scrollbar-width: thin;
-    scrollbar-color: color-mix(in srgb, var(--ink) 28%, transparent) transparent;
-}
-::-webkit-scrollbar { width: 10px; height: 10px; }
-::-webkit-scrollbar-track { background: transparent; }
-::-webkit-scrollbar-thumb {
-    background-color: color-mix(in srgb, var(--ink) 24%, transparent);
-    border-radius: 999px;
-    border: 2px solid transparent;   /* transparante rand → optisch slanker (≈6px) */
-    background-clip: content-box;
-}
-::-webkit-scrollbar-thumb:hover {
-    background-color: color-mix(in srgb, var(--accent) 60%, var(--ink));
-}
-::-webkit-scrollbar-corner { background: transparent; }
-
-/* Login/auth = focus-scherm. Bij htmx-navigatie naar login blijft de bottom-tab
-   uit de vorige pagina in de DOM staan → hier verbergen zodat het login-scherm
-   net zo "kaal" is als bij een full-load (waar de tab niet eens gerenderd wordt). */
-body.on-auth .bottom-tab { display: none; }
+    overflow-x: hidden;
+}
 body:not(.admin) {
     min-height: 100dvh;
@@ -436,13 +252,7 @@
 body:not(.admin)::before {
     content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
-    /* Per-palette achtergrond-gradient: opgebouwd uit de palette's EIGEN --paper/--paper-2
-       (diagonale verloop) + een zachte accent-glow erbovenop. Werkt automatisch voor elk
-       palet, light én dark — geen per-palette regels nodig. */
-    background: radial-gradient(ellipse at top, color-mix(in srgb, var(--accent) 6%, transparent), transparent 60%),
-                radial-gradient(ellipse at bottom right, color-mix(in srgb, var(--accent) 7%, transparent), transparent 55%),
-                linear-gradient(160deg, var(--paper) 0%, var(--paper-2) 100%);
-}
-/* Klonkt-palet gebruikt nu (net als de andere paletten) de generieke accent-glow
-   hierboven → de achtergrond-gradient volgt de gekozen accentkleur. */
+    background: radial-gradient(ellipse at top, color-mix(in srgb, var(--accent) 3%, transparent), transparent 60%),
+                radial-gradient(ellipse at bottom right, color-mix(in srgb, var(--accent) 4%, transparent), transparent 55%);
+}
 ::selection { background: var(--accent-tint); color: var(--accent); }
 
@@ -454,10 +264,4 @@
 a { color: var(--ink); text-decoration: underline; text-decoration-color: var(--rule-2); text-underline-offset: 3px; transition: color var(--transition), text-decoration-color var(--transition); }
 a:hover { color: var(--accent); text-decoration-color: var(--accent); }
-/* Knop-ankers houden hun eigen tekstkleur bij hover. Zonder dit overschrijft de
-   globale a:hover{color:accent} de knop-tekst → die wordt dezelfde kleur als de
-   accent-achtergrond en valt weg (vooral op sites met een felle accent). */
-a.btn:hover, a.epk-btn:hover { color: var(--ink); }
-a.btn-primary:hover, a.btn-danger:hover, a.btn-success:hover, a.button:hover,
-a.epk-btn-primary:hover, a.dls-btn:hover, a.sh-tix:hover, a.dl-go:hover { color: #fff; text-decoration-color: currentColor; }
 a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 2px; text-decoration: none; }
 blockquote { margin: 2em 0; padding: .25em 0 .25em 1.25em; border-left: 2px solid var(--accent); font-family: var(--font-display); font-style: italic; font-size: 1.15em; color: var(--ink-soft); }
@@ -476,9 +280,4 @@
 .container { max-width: 42rem; margin-inline: auto; padding-inline: clamp(1.25rem, 4vw, 2rem); }
 
-/* Post-/leesbreedte. Globaal + hogere specificity (.container.post-page) zodat 'ie
-   de responsive .container-breakpoints (56/72rem) overal overrult — posts én de
-   downloads-pagina krijgen zo exact dezelfde 720px-breedte. */
-.container.post-page { max-width: 720px; margin: 0.5rem auto 2rem; padding: 0 1rem; }
-
 /* Laptop / Full HD — 1440px viewport of groter */
 @media (min-width: 1440px) {
@@ -510,9 +309,7 @@
 .masthead {
     border-bottom: 1px solid var(--rule);
-    padding: calc(1rem + var(--ios-safe-top, 0px)) 0 1rem;
-    /* GEEN backdrop-filter hier: een voorouder met backdrop-filter blokkeert de
-       frosted blur van het user-menu-dropdown eronder. Masthead is toch bijna
-       dekkend, dus solide bg i.p.v. blur. */
-    background: color-mix(in srgb, var(--paper) 94%, transparent);
+    padding: calc(1rem + var(--safe-top)) 0 1rem;
+    background: color-mix(in srgb, var(--paper) 88%, transparent);
+    backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
     position: sticky; top: 0; z-index: 20;
 }
@@ -559,24 +356,4 @@
 
 .empty { text-align: center; padding: 4rem 1rem; }
-/* Moved account (FEP-7628 slice 3): the signpost above the feed. Loud on
-   purpose — a visitor must not read a stale timeline thinking it is current. */
-.moved-banner {
-    text-align: center;
-    margin: 1.5rem 0 2rem;
-    padding: 1.75rem 1.25rem;
-    border: 2px solid var(--accent, #e8b04b);
-    border-radius: var(--radius, 10px);
-    background: color-mix(in srgb, var(--accent, #e8b04b) 10%, var(--paper-2, var(--paper)));
-}
-.moved-banner-lead { margin: 0 0 .5rem; font-size: 1rem; opacity: .85; }
-.moved-banner-link {
-    display: inline-block;
-    font-family: var(--font-display);
-    font-size: clamp(1.5rem, 5vw, 2.4rem);
-    font-weight: 700;
-    line-height: 1.15;
-    overflow-wrap: anywhere;
-}
-.moved-banner-hint { margin: .75rem 0 0; font-size: .85rem; color: var(--ink-soft); }
 .empty h1 { font-size: 2rem; }
 .empty p { color: var(--ink-muted); }
@@ -584,6 +361,5 @@
 /* Embeds */
 .pcms-embed, .folio-embed { margin: 2rem 0; }
-.pcms-embed iframe, .pcms-embed video, .pcms-embed audio,
-.folio-embed iframe, .folio-embed video, .folio-embed audio { width: 100%; display: block; border: 0; border-radius: var(--radius); }
+.pcms-embed iframe, .folio-embed iframe { width: 100%; display: block; border: 0; border-radius: var(--radius); }
 
 /* Article footer */
@@ -692,5 +468,5 @@
 
 /* Footer */
-.site-footer { padding: 2rem 0 calc(2rem + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--rule); margin-top: 4rem; font-family: var(--font-ui); font-size: .85rem; color: var(--ink-muted); }
+.site-footer { padding: 2rem 0 calc(2rem + var(--safe-bot)); border-top: 1px solid var(--rule); margin-top: 4rem; font-family: var(--font-ui); font-size: .85rem; color: var(--ink-muted); }
 .site-footer-inner { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 1rem; align-items: center; }
 .site-footer a { color: var(--ink-muted); text-decoration-color: var(--rule); }
@@ -721,7 +497,7 @@
     display: block;
     width: 100%;
-    height: auto;        /* volledige (niet-bijgesneden) hoogte */
-    /* geen max-height/object-fit: cover meer → de hele afbeelding toont,
-       volle containerbreedte, zijkanten gerespecteerd. */
+    height: auto;
+    max-height: 50vh;
+    object-fit: cover;
 }
 .post-title { font-size: clamp(1.6rem, 4vw, 2.6rem); line-height: 1.15; margin: 0 0 .5rem; letter-spacing: -.02em; }
@@ -783,33 +559,4 @@
 .post-image--right img { margin-left: auto; margin-right: 0; }
 
-/* Afbeeldingen in de post-tekst vullen ALTIJD de containerbreedte en houden hun
-   natuurlijke (niet-bijgesneden) hoogte — geen vaste hoogte / geen crop. */
-.post-content img {
-    display: block;
-    width: 100%;
-    max-width: 100%;
-    height: auto;
-    border-radius: 6px;
-    margin: 1.4em auto;
-}
-
-/* Video in de post-tekst (C2S-uploads uit Shaer): dezelfde maatvoering als
-   afbeeldingen, plus een plafond. Zonder deze regel rendert een <video> op
-   zijn natuurlijke maat en duwt een portret-video de hele kolom vol; met
-   object-fit blijft het beeld heel binnen het kader. Geldt ook voor de
-   Krant/Berichten (tl-content), waar dezelfde content langskomt. */
-.post-content video, .tl-content video {
-    display: block;
-    width: 100%;
-    max-width: 100%;
-    max-height: min(70vh, 540px);
-    height: auto;
-    object-fit: contain;
-    background: #000;
-    border-radius: 6px;
-    margin: 1.4em auto;
-}
-.post-content audio, .tl-content audio { width: 100%; display: block; margin: 1em auto; }
-
 /* Mini-player + bottom-tab clearance: applied to .pcms-main so the footer
    is NOT pushed below the viewport bottom. The bars are position:fixed so
@@ -819,25 +566,4 @@
 .pcms-main { padding-bottom: 5.5rem; }
 body.has-audio-player .pcms-main { padding-bottom: 5.5rem; }
-
-/* Mobiel toetsenbord open (body.kb-open, gezet via visualViewport in shell.ejs):
-   verberg de vaste onderbalken — anders zweven de bottom-tab + mini-speler boven
-   het toetsenbord of overlappen ze het invoerveld. Ze komen terug zodra het
-   toetsenbord sluit. De extra clearance onderaan is dan ook niet meer nodig. */
-body.kb-open .bottom-tab,
-body.kb-open .audio-player { display: none !important; }
-body.kb-open .pcms-main { padding-bottom: 1rem; }
-
-/* Mobiel & tablet (touch): scrollbalken verbergen — scrollen blijft werken, alleen
-   de balk-UI is weg (geen verwarrende/dubbele balken). */
-@media (pointer: coarse) {
-  * { scrollbar-width: none; -ms-overflow-style: none; }
-  *::-webkit-scrollbar { width: 0; height: 0; display: none; }
-}
-
-/* Afbeeldingen lastiger op te slaan (frictie, geen echte beveiliging): geen
-   sleep-naar-bureaublad, geen iOS long-press-bewaarmenu. Rechtsklik wordt in JS
-   geblokkeerd (zie shell.ejs). Covers/hero zijn al CSS-achtergronden = niet via
-   "afbeelding opslaan" te pakken. */
-img { -webkit-user-drag: none; -khtml-user-drag: none; -moz-user-drag: none; user-drag: none; -webkit-touch-callout: none; }
 
 /* ============================================================
@@ -1172,7 +898,8 @@
     }
 
-    /* v9.3 — horizontaal clippen gebeurt nu globaal op html (zie de html-regel
-       bovenaan). Hier bewust NIETS op body zetten: body als scroll-container zou
-       de fixed bottom-tab op mobiel laten meeschuiven. */
+    /* v9.3 — Forceer niets schuift horizontaal weg */
+    body, html {
+        overflow-x: hidden;
+    }
 }
 
@@ -1662,5 +1389,5 @@
         border-top-right-radius: 0;
         box-shadow: none;
-        padding-top: calc(.75rem + var(--ios-safe-top, 0px));
+        padding-top: calc(.75rem + env(safe-area-inset-top, 0px));
         padding-bottom: calc(2rem + env(safe-area-inset-bottom, 0px));
     }
@@ -1686,39 +1413,6 @@
     .pagination-item { width: 100%; }
 }
-
-/* Taal-keuze in de header-nav (NL/EN/DE) — eigen gestylede dropdown. */
-.lang-menu { position: relative; }
-.lang-menu > summary {
-    list-style: none; cursor: pointer;
-    display: inline-flex; align-items: center; gap: .3rem;
-    padding: .35rem .55rem; border-radius: 999px;
-    border: 1px solid var(--rule); background: transparent; color: var(--ink);
-}
-.lang-menu > summary::-webkit-details-marker { display: none; }
-.lang-menu > summary:hover { border-color: var(--accent); }
-.lang-toggle .lang-code { font-size: .8rem; font-weight: 700; letter-spacing: .02em; }
-.lang-toggle .icon { width: 16px; height: 16px; opacity: .8; }
-.lang-caret { width: 12px; height: 12px; opacity: .7; transition: transform .15s; }
-.lang-menu[open] .lang-caret { transform: rotate(180deg); }
-.lang-dropdown {
-    position: absolute; top: calc(100% + 8px); right: 0;
-    min-width: 170px; z-index: 60;
-    background: var(--paper); border: 1px solid var(--rule);
-    border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.22);
-    padding: .35rem; display: flex; flex-direction: column;
-}
-.lang-item {
-    display: flex; align-items: center; gap: .6rem;
-    padding: .5rem .65rem; border-radius: 8px;
-    color: var(--ink); text-decoration: none; font-size: .9rem;
-}
-.lang-item:hover { background: var(--paper-2); }
-.lang-item.is-active { color: var(--accent); font-weight: 600; }
-.lang-item-code {
-    font-size: .7rem; font-weight: 700; opacity: .6;
-    min-width: 1.6em;
-}
 @media (max-width: 400px) {
-    .masthead { padding: calc(.5rem + var(--ios-safe-top, 0px)) 0 .5rem; }
+    .masthead { padding: calc(.5rem + var(--safe-top)) 0 .5rem; }
     .masthead-inner { gap: .25rem; }
     .site-title { font-size: 1.05rem; }
@@ -1971,18 +1665,10 @@
     overflow: hidden;
     border-radius: var(--radius);
-    /* Accent-gradient placeholder while the cover image loads (image fades in over it). */
-    background-image: linear-gradient(135deg,
-        color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2, var(--paper))) 0%,
-        color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2, var(--paper))) 100%);
-}
-.post-list-cover img, .post-list-cover video {
+}
+.post-list-cover img {
     width: 100%; height: 100%;
-    object-fit: cover;                 /* fill the square, keep aspect, crop the overflow, centered */
-    object-position: center;
+    object-fit: cover;
     display: block;
-    background-color: #fff;            /* transparent logos sit on white once loaded */
-    transition: opacity .4s ease;
-}
-.post-list-cover img.is-loading { opacity: 0; }
+}
 .post-list-body { display: flex; flex-direction: column; gap: .35rem; }
 .post-list-meta {
@@ -2254,6 +1940,5 @@
 }
 .profile-links li { margin: 0; }
-.profile-links a,
-.profile-links .profile-fedi-link {
+.profile-links a {
     display: inline-flex;
     align-items: center;
@@ -2267,6 +1952,5 @@
     transition: background .15s, color .15s;
 }
-.profile-links a:hover,
-.profile-links .profile-fedi-link:hover {
+.profile-links a:hover {
     background: color-mix(in srgb, var(--accent) 15%, transparent);
     color: var(--accent);
@@ -2341,6 +2025,5 @@
     background: var(--paper);
     /* Bottom margin gives breathing room between the switcher bar and the
-       first post. margin-top staat in shared-styles.ejs (de winnende inline
-       <style>) — symmetrisch op 1.5rem. */
+       first post — without it the feed sits flush against the bar. */
     margin-bottom: 1.5rem;
 }
@@ -2372,27 +2055,18 @@
     border-color: var(--ink-muted);
 }
-/* Uitgeschakeld (Lezen in de cirkel) wint van :hover -- anders licht een knop
-   op die je niet kunt indrukken, en dat is precies de verkeerde belofte. */
-.view-switch-btn[disabled],
-.view-switch-btn[disabled]:hover {
-    opacity: .38;
-    cursor: not-allowed;
-    color: var(--ink-muted);
-    border-color: var(--rule);
-}
-/* Actieve state: puur afgeleid van body[data-feed-view]. Geen .is-active class meer.
-   Niet op de Archive-pagina: daar is geen "huidige" timeline/grid-keuze, dus beide
-   knoppen blijven neutraal (grijs). */
-/* Actieve Tijdlijn/Grid-state wordt nu gestuurd via [aria-selected="true"] (gezet
-   door syncAria, die óók op htmx:afterSettle draait) i.p.v. body[data-feed-view].
-   Reden: body-class/attribuut-CSS pakte niet betrouwbaar na een htmx-OOB-swap;
-   een element-attribuut wel. De witte active-regel staat in shared-styles. */
+/* Actieve state: puur afgeleid van body[data-feed-view]. Geen .is-active class meer. */
+body[data-feed-view="timeline"] .view-switch-btn[data-view="timeline"],
+body[data-feed-view="grid"] .view-switch-btn[data-view="grid"] {
+    background: var(--ink);
+    color: var(--paper);
+    border-color: var(--ink);
+}
 .view-switch-btn .icon { width: 1rem; height: 1rem; }
 
 /* -------- FEED TOGGLE VIA BODY --------
-   Grid-modus werkt op de feed (home, cirkel). Het ARCHIEF negeert de grid-keuze
-   bewust: dat is altijd de chronologische jaar/maand-lijst (de switcher is daar
-   grijs en springt bij klik terug naar de feed). Post-pagina's tonen de
-   feed-timeline article sowieso altijd. Alle andere body-classes negeren
+   Grid-modus werkt op elke feed-pagina (home, archive). Op een post-pagina
+   is de feed-timeline article juist het hoofdonderwerp — daar moet 'ie
+   altijd zichtbaar zijn, dus de grid-toggle slaat alleen aan op
+   feed-pagina's (.on-home, .on-archive). Andere body-classes negeren de
    data-feed-view en houden timeline visible. */
 body.on-home[data-feed-view="grid"]    .feed-timeline,
@@ -2400,409 +2074,12 @@
 body.on-home[data-feed-view="grid"]    .related-posts,
 body.on-home[data-feed-view="grid"]    .article-footer,
-body.on-cirkel[data-feed-view="grid"]  .feed-timeline {
+body.on-archive[data-feed-view="grid"] .feed-timeline,
+body.on-archive[data-feed-view="grid"] .pagination {
     display: none !important;
 }
-/* Grid-sectie alleen zichtbaar op de feed in grid-mode (NIET op archief) */
+/* Grid-sectie alleen zichtbaar op feed-pagina's in grid-mode */
 .feed-grid { display: none; }
 body.on-home[data-feed-view="grid"]    .feed-grid,
-body.on-cirkel[data-feed-view="grid"]  .feed-grid { display: block; }
-
-/* -------- LEESWEERGAVE (data-feed-view="reader") --------
-   Dezelfde berichten als de tijdlijn, maar heel: één per scherm, grenzen door
-   scroll-snapping. GEEN eigen route en geen eigen shell -- de balken blijven
-   staan, want je bent nog op de feed en moet terug kunnen schakelen.
-   Pagina's zonder .feed-reader (cirkel, archief, post) negeren de keuze en
-   houden hun tijdlijn: er is hieronder simpelweg geen regel die die daar
-   verbergt. Zo is Lezen nooit een leeg scherm. */
-.feed-reader { display: none; }
-body.on-home[data-feed-view="reader"] .feed-reader { display: block; }
-/* :has(.feed-reader) is hier de LEGE STAAT, niet een truc. Zonder berichten
-   rendert de leessectie niet, en dan mag de tijdlijn niet verborgen worden --
-   want dáár staat "nog niets geschreven" met de knop om te beginnen. Zonder deze
-   voorwaarde is Lezen op een verse site een wit scherm. Hetzelfde geldt voor
-   feeds die (nog) geen leessectie hebben. */
-body.on-home[data-feed-view="reader"]:has(.feed-reader) .feed-timeline,
-body.on-home[data-feed-view="reader"]:has(.feed-reader) .feed-grid,
-body.on-home[data-feed-view="reader"]:has(.feed-reader) .pagination,
-body.on-home[data-feed-view="reader"]:has(.feed-reader) .related-posts,
-body.on-home[data-feed-view="reader"]:has(.feed-reader) .article-footer {
-    display: none !important;
-}
-
-/* -------- AUTO (data-feed-view="auto") --------
-   Lezen op mobiel, Tijdlijn op desktop. GEEN JavaScript: de CSS kiest, dus geen
-   resize-afhandeling en geen flikkering waarin je eerst de verkeerde weergave
-   ziet. De tijdlijn is de terugval, dus boven de grens hoeft hier niets te
-   staan -- die is vanzelf al zichtbaar.
-   De :has(.feed-reader)-voorwaarde staat er om dezelfde reden als hierboven:
-   zonder berichten is er geen leessectie, en dan moet de tijdlijn blijven staan
-   met "nog niets geschreven" erin. */
-@media (max-width: 767px) {
-    body.on-home[data-feed-view="auto"] .feed-reader { display: block; }
-    body.on-home[data-feed-view="auto"]:has(.feed-reader) .feed-timeline,
-    body.on-home[data-feed-view="auto"]:has(.feed-reader) .feed-grid,
-    body.on-home[data-feed-view="auto"]:has(.feed-reader) .pagination,
-    body.on-home[data-feed-view="auto"]:has(.feed-reader) .related-posts,
-    body.on-home[data-feed-view="auto"]:has(.feed-reader) .article-footer {
-        display: none !important;
-    }
-}
-/* In de auto-stand staan BEIDE knoppen in de HTML en laat de mediaquery er een
-   zien. Zo verspringt de naam van de knop niet terwijl je ernaar kijkt, en is er
-   geen JavaScript nodig dat op resize moet luisteren. */
-.view-switch-btn.vs-alleen-mobiel { display: none; }
-@media (max-width: 767px) {
-    .view-switch-btn.vs-alleen-mobiel { display: inline-flex; }
-    .view-switch-btn.vs-alleen-desktop { display: none; }
-}
-
-/* Snappen doet de SCROLLCONTAINER, en dat is het document -- niet .read-stream,
-   die heeft overflow: visible en scrollt zelf niet. Proximity en geen mandatory:
-   in een bericht dat langer is dan het scherm moet je rustig door kunnen lezen. */
-html:has(body.on-home[data-feed-view="reader"]) {
-    scroll-snap-type: y proximity;
-    /* GEEN scroll-behavior:smooth hier. Dat maakte elke beweging op deze
-       scroller traag, ook het snappen zelf -- en een snap moet vlot zijn
-       (Robin, 20-8: "vlot maar geanimeerd, mag niet te lang duren"). De browser
-       animeert een snap uit zichzelf al, en korter dan zijn smooth-scroll.
-       De terug-naar-boven-knop vraagt zijn animatie expliciet aan in
-       mod/read.js, dus die blijft vloeiend. */
-}
-/* En de browser niet óók laten compenseren als Load more er berichten bij zet:
-   Chrome en Firefox schuiven bij invoegen zelf mee (scroll anchoring), en dat
-   vecht met het snappen. */
-html:has(body.on-home[data-feed-view="reader"]),
-.feed-reader .read-stream { overflow-anchor: none; }
-
-.feed-reader .read-post {
-    /* Snap naar de BOVENkant van een bericht, zacht (proximity op html) en nooit
-       dwingend (stop: normal). Een korte post vult het scherm, zodat hij niet als
-       snipper tussen twee lijnen hangt.
-       De uitzondering staat NIET hier maar in mod/read.js: een bericht dat langer
-       is dan het scherm krijgt scroll-snap-align:none, anders trekt de browser je
-       tijdens het lezen terug naar zijn bovenrand en lijkt het of het scrollen
-       stopt. "Past het op het scherm?" is een vraag over gemeten hoogte, en die
-       kan CSS niet stellen. */
-    /* Je LANDT op de bovenkant van een bericht -- dat is wat "snap to post"
-       hoort te betekenen. (align:end probeerde ik ook; dan land je op de
-       ONDERKANT van het vorige bericht, en bij een lang bericht zie je dus zijn
-       laatste schermvol in plaats van de kop van het volgende.)
-       Wanneer die bovenkant een snappunt IS, staat in mod/read.js: alleen zolang
-       je er nog niet voorbij bent. Eenmaal in een bericht snapt er niets meer,
-       ook niet naar zijn eigen bovenrand -- anders trekt proximity je terug
-       zodra je een paar regels verder leest. */
-    /* Het snappunt zit op .read-anker hierna, niet hier. Zie de toelichting
-       daar: een snapgebied groter dan het scherm heeft een BEREIK aan geldige
-       posities, en de bovenste daarvan zet het volgende bericht op de onderrand. */
-    scroll-snap-align: none;
-    scroll-snap-stop: normal;
-    /* GEEN min-height meer. Die rekte elk bericht tot een vol scherm, en bij een
-       kort bericht leverde dat een halve pagina leegte op onder "Replies and
-       reactions" -- je zat naar niets te kijken en moest verder scrollen om te
-       ontdekken dat er nog iets kwam. Robins vondst (20-8), met een
-       schermafbeelding erbij.
-       Zonder die hoogte is de stroom gewoon bericht na bericht: je ziet het
-       volgende meteen onder het vorige beginnen. Het snappen doet nog steeds
-       zijn werk aan de grens -- dat hangt aan scroll-snap-align en niet aan de
-       hoogte -- dus de streep lijnt nog altijd uit met de bovenkant van het
-       volgende bericht. */
-    max-width: 46rem;
-    margin: 0 auto;
-    /* De onderrand loopt achter de vaste tabbalk door; deze padding houdt de
-       TEKST erboven. De BOVENmarge is verdubbeld (Robin, 20-8: 2rem -> 4rem):
-       na een snap staat de scheidingslijn op de bovenrand, en de tekst
-       daaronder krijgt meer lucht. MOET GELIJK BLIJVEN aan scroll-margin-top
-       op .read-anker hieronder -- lopen die uit elkaar, dan landt de lijn niet
-       meer precies op de bovenrand. */
-    padding: 4rem 1.2rem 4rem;
-    box-sizing: border-box;
-}
-/* ONDERIN GEBEURT ER NIETS (Robin, 20-8: "enkel de bovenkant mag snappen, niet
-   de onderkant; onderin mag er niks met de zone gebeuren").
-   Het laatste bericht heeft een snappunt dat de browser NIET KAN BEREIKEN: er
-   zit niet genoeg pagina onder om zijn bovenkant naar de bovenrand te brengen.
-   Gemeten op dev: snappunt 2548, maar de scroll gaat maar tot 2356. De spec
-   laat de browser dan naar de dichtstbijzijnde BEREIKBARE positie snappen, en
-   dat is de bodem -- waardoor het laatste bericht ergens middenin het scherm
-   blijft hangen en het lijkt of er aan de onderkant wordt uitgelijnd.
-   Zonder snappunt scrolt dat laatste stuk gewoon vrij uit tot het einde. */
-/* HET SNAPPUNT. Nul hoog, dus precies EEN geldige positie: deze bovenrand aan de
-   bovenrand van het scherm. Zat de uitlijning op het artikel zelf, dan gold voor
-   elk bericht dat hoger is dan het scherm een heel BEREIK aan geldige posities
-   (spec: een snapgebied groter dan de snapport mag overal staan waar het de
-   snapport nog vult). Gemeten op dev: bericht van 1009px op een scherm van 812
-   -> geldig van 700 tot 897, en op 867 bleef hij dus gewoon staan. Die bovenste
-   stand, 897, is de onderkant van dat bericht op de onderrand -- oftewel de
-   bovenkant van het VOLGENDE bericht op de onderrand. Dat is wat er onderin
-   leek te snappen, en het kwam van de browser en niet uit onze code. */
-.feed-reader .read-anker {
-    display: block;
-    height: 0;
-    scroll-snap-align: start;
-    /* Het anker staat BINNEN de padding van het artikel, dus een padding-top
-       onder de streep. Zonder deze correctie landt de snap net zoveel te laag
-       en staat de scheidingslijn boven beeld -- gemeten: 733 waar 700 hoort.
-       scroll-margin verschuift alleen het SNAPPUNT en niet de opmaak, dus het
-       anker blijft waar het staat en de uitlijning klopt met de lijn.
-       GELIJK aan de padding-top van .read-post hierboven; die ging 20-8 van
-       2rem naar 4rem, dus deze ook. */
-    scroll-margin-top: 4rem;
-}
-/* HET LAATSTE BERICHT SNAPT OOK (Robin, 20-8). Dat kon eerst niet: zijn
-   bovenkant was onbereikbaar -- er zat te weinig pagina onder om hem naar de
-   bovenrand te brengen (gemeten: snappunt 2548, maxScroll 2356), en de browser
-   viel dan terug op de dichtstbijzijnde bereikbare positie: de bodem. Het
-   snappunt weghalen was de vorige oplossing; dit is de echte.
-   Een volle schermhoogte op het LAATSTE bericht maakt die bovenkant precies
-   bereikbaar. Korter dan het scherm: het bericht wordt even hoog en het snappunt
-   valt exact op maxScroll. Langer dan het scherm: dan was het al haalbaar en
-   doet min-height niets.
-   100lvh en niet 100svh: op een telefoon met een verdwijnende adresbalk is het
-   zichtbare scherm soms de GROTE viewport, en dan komt svh net tekort.
-   Zichtbaar kost dit niets: berichten hebben geen kader, alleen een scheidings-
-   lijn erboven, dus het laatste bericht krijgt hooguit witruimte eronder. */
-.feed-reader .read-post:last-child { min-height: 100lvh; }
-
-/* ELK BERICHT EEN EIGEN SCHERM, als de site dat wil (Instellingen -> Weergave).
-   Aan: een kort bericht vult het scherm en je ziet het volgende pas na een snap.
-   Uit (standaard): de stroom is bericht na bericht, zonder lege ruimte eronder.
-   Dit stond eerst vast op AAN, is toen vast op UIT gezet omdat een kort bericht
-   een halve pagina leegte gaf onder "Replies and reactions", en is nu een keuze
-   -- want welke van de twee prettiger leest hangt af van wat je schrijft. */
-body[data-reader-pages="1"] .feed-reader .read-post { min-height: 100svh; }
-/* OP MOBIEL ALTIJD, wat de instelling ook zegt (Robin, 20-8). Op een telefoon is
-   een bericht per scherm de vorm die werkt -- je veegt van bericht naar bericht.
-   Op desktop blijft het een keuze, want daar lees je in een kolom naast veel
-   witruimte en is een doorlopende stroom vaak prettiger. */
-@media (hover: none) and (pointer: coarse) {
-    /* Op mobiel draait Lezen in paginamodus (body.is-paged), en die zet hieronder
-       een vaste hoogte. Deze regel is de terugval voor het moment vóór de module
-       geladen is, zodat de berichten niet eerst als losse stroom in beeld
-       springen. */
-    .feed-reader .read-post { min-height: 100svh; }
-/* Geen native snap op mobiel: dat doet lenis/snap, met een instelbare vangzone
-       en duur. Twee snapsystemen op dezelfde scroller vechten. */
-    html:has(body.on-home[data-feed-view="reader"]) { scroll-snap-type: none; }
-}
-
-/* -------- PAGINAMODUS --------
-   Elk bericht is een paneel van precies een scherm dat ZELF scrollt; tussen
-   berichten ga je met knoppen. Daarmee doet het scrollgevoel er niet meer toe:
-   geen momentum om na te bouwen en geen snap die vangt.
-   De stroom zelf vangt geen vingers meer -- dat regelt lenis.stop() in
-   mod/read.js -- en data-lenis-prevent op het paneel houdt de scroll BINNEN een
-   bericht ongemoeid (die controle staat in Lenis vóór de gestopt-controle).
-   Geldt waar een bericht een eigen scherm heeft: op mobiel altijd, op desktop
-   als de site dat zo instelt. */
-body.is-paged .feed-reader .read-post {
-    height: 100svh;
-    min-height: 0;
-    overflow-y: auto;
-    overscroll-behavior: contain;   /* raakt het einde, dan niet doorschuiven */
-    scroll-snap-align: none;
-    /* Ruimte voor de twee balken, zodat er nooit tekst onder een knop verdwijnt.
-       Het verloop laat een regel wel zacht wegvagen, maar wat je moet KUNNEN
-       lezen hoort er niet achter te komen -- vandaar padding en niet alleen die
-       fade. --read-balk staat hieronder bij .read-nav, zodat de twee niet uit
-       elkaar kunnen lopen. */
-    padding-top: calc(var(--read-balk) + .5rem);
-    padding-bottom: calc(var(--read-balk) + .5rem);
-    /* BOVENAAN beginnen, niet gecentreerd. Ik had de inhoud een ronde lang
-       verticaal gecentreerd om het gat onder een kort bericht te verdelen, maar
-       dan hangt zo'n bericht halverwege het scherm en begint niet elk paneel op
-       dezelfde plek -- dat leest onrustig. Robin ving het (20-8). Elk bericht
-       begint nu bovenaan, en de ruimte die overblijft valt onderaan. */
-}
-body.is-paged .feed-reader .read-anker { scroll-snap-align: none; }
-
-/* DE DYNAMIC ISLAND, en met opzet alleen op iOS (Robin, 20-8: "op android is het
-   een ander verhaal").
-   In paginamodus vult een bericht precies het scherm, dus de eerste regel komt
-   bovenaan -- en op een iPhone ligt daar de island of de notch overheen.
-   env(safe-area-inset-top) geeft daar de vrije ruimte; viewport-fit=cover staat
-   al in de viewport-meta, dus die waarde is echt.
-   De @supports-regel is de iOS-Safari-toets: -webkit-touch-callout bestaat alleen
-   daar. Op Android is env() vaak 0 of betekent het iets anders (camera-uitsparing,
-   statusbalk die al meegerekend is), en dan zou dit een gat bovenin maken dat er
-   niet hoort. Vandaar afgeschermd in plaats van env() overal toepassen.
-   De hoogte krimpt mee, anders duwt de padding het paneel groter dan het scherm
-   en krijg je alsnog scroll tussen de berichten. */
-@supports (-webkit-touch-callout: none) {
-    body.is-paged .feed-reader .read-post {
-        padding-top: calc(var(--read-balk) + .5rem + env(safe-area-inset-top, 0px));
-        height: calc(100svh - env(safe-area-inset-top, 0px));
-    }
-    /* De bovenbalk houdt zijn knop al vrij van de island via padding-top
-       hierboven; hier hoeft niets extra's meer. */
-}
-
-/* De knop onderaan een bericht: naar het volgende. */
-
-
-/* Omhoog en omlaag: twee balken, boven en onder, die BLIJVEN staan terwijl je in
-   een bericht scrolt (Robin, 20-8). Het verloop van paginakleur naar doorzichtig
-   laat de tekst eronderdoor wegvagen -- zonder dat zou een regel halverwege hard
-   achter de knop verdwijnen en oogt het als een afgeknipte pagina.
-   De BALK vangt geen aanrakingen (pointer-events: none), alleen de knop zelf --
-   anders zou een strook van 5rem boven- en onderaan je scroll opeten. */
-/* TWEE BALKEN over de volle breedte, boven en onder. Eerder waren dit losse
-   cirkels (te rommelig naast de tabbalk en de speler) en daarna onzichtbare
-   tikzones -- maar die werkten niet, omdat de onderste strook onder de
-   mini-speler en de tabbalk ligt en die de tik opvangen. Een balk met een eigen
-   z-index kan daar wel overheen.
-   Slank gehouden: ze eten hooguit 2.6rem aan elke kant, en het paneel houdt daar
-   al ruimte voor vrij. */
-:root { --read-balk: 2.6rem; }
-.read-bar {
-    position: fixed; left: 0; right: 0; z-index: 1060;   /* boven tabbalk (1050) */
-    display: flex; align-items: center;
-    /* Pijlen LINKS in de balk, niet gecentreerd (Robin, 20-8). */
-    justify-content: flex-start;
-    padding-left: max(1.1rem, env(safe-area-inset-left, 0px));
-    box-sizing: border-box;
-    height: var(--read-balk);
-    border: 0;
-    border-top: 1px solid transparent;
-    background: color-mix(in srgb, var(--paper) 90%, var(--ink));
-    backdrop-filter: blur(14px) saturate(140%);
-    -webkit-backdrop-filter: blur(14px) saturate(140%);
-    color: var(--ink);
-    cursor: pointer;
-    transition: opacity .15s ease;
-}
-.read-bar--top {
-    top: 0;
-    height: calc(var(--read-balk) + env(safe-area-inset-top, 0px));
-    padding-top: env(safe-area-inset-top, 0px);
-    border-bottom: 1px solid color-mix(in srgb, var(--ink) 14%, transparent);
-}
-.read-bar--bottom {
-    /* De waarde komt uit mod/read.js: die meet hoe hoog de tabbalk en de
-       mini-speler werkelijk staan. Geraden rem-waarden lieten hem zweven -- een
-       kier boven de speler, of er half achter. Dit is de terugval voor de eerste
-       frame en voor als de module niet laadt. */
-    bottom: 0;
-    border-top: 1px solid color-mix(in srgb, var(--ink) 14%, transparent);
-}
-.read-bar:active { background: color-mix(in srgb, var(--paper) 78%, var(--ink)); }
-.read-bar[hidden], body:not(.is-paged) .read-bar { display: none; }
-
-/* Een lijn tussen twee berichten: zonder grens is een stroom een lange brij. */
-.feed-reader .read-post + .read-post { border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
-
-/* De cover vult de leeskolom. Eigen regel, niet .post-cover hergebruikt: die
-   heeft een marge van 2rem die hier tegen de scroll-snap aan duwt.
-   De hoogtebegrenzing is er met opzet. Een paneel is min-height:100svh en snapt,
-   dus een staande foto op volle breedte zou de titel en de eerste alinea onder
-   de vouw duwen -- dan begint elk bericht met een plaat waar je langs moet
-   scrollen om te zien wat het IS. */
-.feed-reader .read-cover {
-    /* Volle kolombreedte: de afbeelding erin neemt die breedte, dus de figure
-       hoeft niet meer om het beeld te krimpen. */
-    width: 100%;
-    margin: 0 0 1.2rem;
-    border-radius: 10px;
-    overflow: hidden;
-}
-.feed-reader .read-cover img,
-.feed-reader .read-cover video {
-    display: block;
-    /* Neemt de VOLLE kolombreedte, met de verhouding intact (object-fit
-       weggelaten, dus geen bijsnijden). Een cover die van zichzelf kleiner is
-       wordt netjes tot de kolom opgeschaald in plaats van als postzegeltje te
-       blijven staan -- dat was te klein. De hoogtegrens houdt een staande foto
-       nog binnen de perken zodat titel + eerste alinea boven de vouw blijven;
-       hij krimpt dan mee in plaats van bijgesneden te worden. */
-    display: block;
-    width: 100%;
-    height: auto;
-    max-height: 70svh;
-    object-fit: contain;
-    /* Een logo met doorzichtige achtergrond zweeft anders op de paginakleur;
-       dezelfde keuze als bij .post-list-cover in de tijdlijn. */
-    background-color: #fff;
-}
-
-.feed-reader .read-head { padding-bottom: .5rem; }
-.feed-reader .read-title { font-size: clamp(1.6rem, 4vw, 2.4rem); line-height: 1.15; margin: 0 0 .3rem; }
-/* Het pin-icoon voor de titel: meeschalend met de kop, maar duidelijk kleiner --
-   het is een markering en geen deel van de titel. */
-.feed-reader .read-pin {
-    display: inline-block;
-    vertical-align: baseline;
-    width: .62em; height: .62em;
-    margin-right: .35em;
-    color: var(--ink-muted);
-}
-.feed-reader .read-pin svg { width: 100%; height: 100%; display: block; }
-/* De titel is een link, maar mag er niet als een link uitzien: dit is een
-   leesscherm, geen index. */
-.feed-reader .read-title a { color: inherit; text-decoration: none; }
-.feed-reader .read-title a:hover,
-.feed-reader .read-title a:focus-visible { text-decoration: underline; }
-.feed-reader .read-when { font-size: .85rem; opacity: .6; margin: 0; }
-
-.feed-reader .read-foot { margin-top: 2rem; }
-.feed-reader .read-open {
-    display: inline-block; font-size: .9rem; opacity: .75;
-    text-decoration: none; border-bottom: 1px solid currentColor; padding-bottom: 1px;
-}
-.feed-reader .read-open:hover,
-.feed-reader .read-open:focus-visible { opacity: 1; }
-
-/* Terug naar boven. Rechtsonder, want daar zit de duim -- en boven de tabbalk,
-   die op mobiel 1050 is en anders overheen zou vallen. Onzichtbaar tot je ver
-   genoeg bent, zodat hij niet meteen in de weg staat bij het eerste bericht. */
-.read-top {
-    position: fixed;
-    right: max(1rem, env(safe-area-inset-right, 0));
-    bottom: calc(env(safe-area-inset-bottom, 0) + 4.75rem);
-    z-index: 1040;
-    display: grid;
-    place-items: center;
-    width: 44px;
-    height: 44px;                 /* 44px = de kleinste maat die een duim betrouwbaar raakt */
-    border-radius: 999px;
-    border: 1px solid var(--rule);
-    background: color-mix(in srgb, var(--paper) 88%, transparent);
-    backdrop-filter: blur(14px) saturate(140%);
-    -webkit-backdrop-filter: blur(14px) saturate(140%);
-    color: var(--ink);
-    box-shadow: 0 2px 12px color-mix(in srgb, var(--ink) 12%, transparent);
-    cursor: pointer;
-    opacity: 0;
-    pointer-events: none;
-    transition: opacity .18s ease, transform .18s ease;
-    transform: translateY(6px);
-}
-.read-top.is-zichtbaar { opacity: 1; pointer-events: auto; transform: none; }
-/* Buiten de leesweergave bestaat hij niet -- home.ejs rendert alle drie de
-   secties, dus zonder deze regel zweeft hij ook boven Tijdlijn en Grid. */
-body:not([data-feed-view="reader"]) .read-top,
-body:not(.on-home) .read-top { display: none; }
-/* Op desktop is er geen tabbalk, dus mag hij lager staan. */
-@media (min-width: 768px) {
-    .read-top { bottom: 1.5rem; right: 1.5rem; }
-}
-/* Staat de mini-speler onderin, dan wijkt de knop daarvoor -- anders komt hij er
-   bovenop te liggen (Robins schermafbeelding, 20-8). body.has-audio-player wordt
-   door audio-player.js gezet zodra de speler zichtbaar is, en weer weggehaald
-   als hij verdwijnt, dus dit volgt vanzelf.
-   Op mobiel staat de speler BOVEN de tabbalk gestapeld, dus daar komt zijn
-   hoogte bovenop de ruimte die er al gereserveerd was. */
-body.has-audio-player .read-top { bottom: calc(env(safe-area-inset-bottom, 0) + 4.75rem + 4.25rem); }
-@media (min-width: 768px) {
-    body.has-audio-player .read-top { bottom: calc(1.5rem + 4.25rem); }
-}
-@media (prefers-reduced-motion: reduce) {
-    .read-top { transition: none; transform: none; }
-}
-
-@media (prefers-reduced-motion: reduce) {
-    html:has(body.on-home[data-feed-view="reader"]) {
-        scroll-snap-type: none;
-        scroll-behavior: auto;
-    }
-}
+body.on-archive[data-feed-view="grid"] .feed-grid { display: block; }
 
 /* -------- GRID VIEW TILES (Instagram-stijl) -------- */
@@ -2829,45 +2106,4 @@
     -webkit-tap-highlight-color: rgba(0,0,0,.1);
 }
-
-/* Transparent covers (PNG logos etc.) sit on white so they don't blend into a dark
-   theme. The white is on the IMG, so the tile's accent-gradient placeholder shows
-   while the image is still loading. */
-.post-cover img,
-.post-list-cover img,
-.grid-tile-img { background-color: #fff; }
-/* Accent-gradient placeholder shown while the cover image loads (the image fades in
-   over it), instead of a white flash on cover tiles. */
-.grid-tile:not(.grid-tile-gradient) {
-    background-image: linear-gradient(135deg,
-        color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2, var(--paper))) 0%,
-        color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2, var(--paper))) 100%);
-}
-/* Cover as a real <img> (not background-image): browsers downsample <img> with the
-   high-quality image pipeline, so high-res line-art covers don't go jagged when shrunk.
-   Fades in on load (.is-loading removed) so the placeholder shows meanwhile. */
-.grid-tile-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; display: block; transition: opacity .4s ease; }
-.grid-tile-img.is-loading { opacity: 0; }
-/* Audio tile (Shaer, Robins vraag 30-7): the waveform PNG is white on
-   transparent and floats OVER the tile's own hue gradient, so every audio
-   post keeps its color and gets its shape. Wave and title each get their
-   own band (wave upper, title lower): centering both stacked them right
-   through each other (Robins schermafdruk). */
-.grid-tile-img.grid-tile-wave {
-    background-color: transparent;
-    object-fit: contain;
-    /* explicit box, not auto: an absolutely positioned img falls back to its
-       intrinsic size when width/height are auto, collapsing the band */
-    top: 8%; left: 8%; right: auto; bottom: auto;
-    width: 84%; height: 46%;
-    opacity: .85;
-}
-.grid-tile-audio .grid-tile-title { position: absolute; left: 10%; right: 10%; top: 60%; }
-.grid-tile-audio .grid-tile-gradient-src { position: absolute; left: 10%; right: 10%; top: 76%; }
-/* Cover videos are decorative, not players: let clicks + right-click pass through to the
-   tile link so you get the normal link menu (not the browser's <video> menu/controls).
-   Autoplay is independent of pointer-events, so the video still plays. */
-video.grid-tile-img,
-.post-list-cover video,
-.post-cover video { pointer-events: none; }
 .grid-tile:hover { transform: scale(0.98); }
 .grid-tile-overlay {
@@ -2876,7 +2112,5 @@
     padding: .6rem;
     display: flex;
-    flex-direction: column;
-    justify-content: flex-end;
-    align-items: flex-start;
+    align-items: flex-end;
     background: linear-gradient(to top, rgba(0,0,0,.65) 0%, rgba(0,0,0,0) 50%);
     opacity: 0;
@@ -2884,19 +2118,4 @@
     pointer-events: none; /* klik moet naar de <a> eronder gaan, niet naar de overlay */
 }
-/* Bron-site (cirkel-feed): toont bij hover "van <site>" onder de titel. */
-/* Source label: small text that wraps to 2 lines if needed, with a big chain icon
-   beside it (the text was 1.44rem — bigger than the title — and overflowed on mobile). */
-.grid-tile-overlay-src {
-    color: #fff; opacity: .85;
-    font-size: .72rem; line-height: 1.2; margin-top: .25rem;
-    display: flex; align-items: center; gap: .3rem; width: 100%;
-}
-.grid-tile-overlay-src::before { content: "⛓"; font-size: 1.3rem; line-height: 1; flex: 0 0 auto; }
-.grid-tile-gradient-src {
-    margin-top: .35rem; font-size: .72rem; line-height: 1.2; opacity: .85;
-    color: var(--ink-soft);
-    display: flex; align-items: center; gap: .3rem; width: 100%;
-}
-.grid-tile-gradient-src::before { content: "⛓"; font-size: 1.3rem; line-height: 1; flex: 0 0 auto; }
 .grid-tile-overlay strong {
     color: #fff;
@@ -2923,5 +2142,4 @@
     background-image: linear-gradient(135deg, hsl(var(--tile-hue, 30), 45%, 88%), hsl(calc(var(--tile-hue, 30) + 30), 35%, 75%));
     display: flex;
-    flex-direction: column;
     align-items: center;
     justify-content: center;
@@ -2944,10 +2162,4 @@
     overflow: hidden;
     z-index: 1;
-    /* Federated link posts can have a long bare URL as their title — break it so it wraps
-       inside the tile and clamps cleanly instead of overflowing past both edges. */
-    max-width: 100%;
-    overflow-wrap: anywhere;
-    word-break: break-word;
-    hyphens: auto;
 }
 
@@ -2972,21 +2184,17 @@
     pointer-events: none;
 }
-.grid-tile-pin svg {
-    width: .95em;
-    height: .95em;
-    margin-top: -1px;
-}
-/* Pinned/boosted tile: accent ring. An outline on the tile is painted UNDER the absolute
-   cover <img>, so draw the ring as a pseudo-element overlay ABOVE the image (z-index above
-   the img, below the corner badges + nsfw veil). */
-.grid-tile.is-pinned { position: relative; z-index: 1; }
-.grid-tile.is-pinned::after {
-    content: '';
-    position: absolute;
-    inset: 0;
-    border: 3px solid var(--accent);
-    border-radius: inherit;
-    pointer-events: none;
+.grid-tile-pin::before {
+    content: "📌";
+    font-size: .9em;
+    line-height: 1;
+}
+/* Pinned-tile overall highlight: accent border + subtle inner glow */
+.grid-tile.is-pinned {
+    outline: 3px solid var(--accent);
+    outline-offset: -3px;
     z-index: 1;
+}
+.grid-tile.is-pinned:hover {
+    outline-offset: -2px;
 }
 .grid-tile-type {
@@ -3156,35 +2364,4 @@
 }
 
-/* Solo / Cirkel scope-switcher (alleen in circle-modus) — segmented toggle, centraal.
-   Hoge specificiteit (+ !important op tekst) zodat 'ie globale link-stijlen wint. */
-.view-switcher-wrap .feed-scope {
-    display: inline-flex;
-    background: var(--paper-2);
-    border: 1px solid var(--rule);
-    border-radius: 999px;
-    padding: 3px;
-}
-.view-switcher-wrap .feed-scope-btn {
-    display: inline-flex;
-    align-items: center;
-    padding: .32rem .95rem;
-    min-height: 30px;
-    border: none !important;
-    border-radius: 999px !important;
-    background: transparent !important;
-    color: var(--ink-muted) !important;
-    font-family: var(--font-ui);
-    font-size: .82rem;
-    font-weight: 500 !important;
-    text-decoration: none !important;
-    cursor: pointer;
-    transition: all .15s;
-}
-.view-switcher-wrap .feed-scope-btn:hover { color: var(--ink) !important; }
-.view-switcher-wrap .feed-scope-btn.is-active {
-    background: var(--ink) !important;
-    color: var(--paper) !important;
-}
-
 .grid-cols-picker {
     display: none; /* default hidden — desktop + grid-active maakt 'm zichtbaar */
@@ -3221,17 +2398,17 @@
 }
 
-/* Desktop + grid-view → toon cols-picker (op de home-feed; niet op archief) */
+/* Desktop + grid-view → toon cols-picker (op home + archive) */
 @media (min-width: 600px) {
-    body.on-home[data-feed-view="grid"] .grid-cols-picker {
+    body.on-home[data-feed-view="grid"] .grid-cols-picker,
+    body.on-archive[data-feed-view="grid"] .grid-cols-picker {
         display: inline-flex;
     }
 }
 
-/* Kolom-overrides op desktop. Mobiel negeert dit (staat vast op 2). Geldt op ELKE
-   feed met grid (home én cirkel/tag/type/user) — scope op .feed-grid .grid-tiles. */
+/* Kolom-overrides op desktop. Mobiel negeert dit (staat vast op 2). */
 @media (min-width: 600px) {
-    body[data-grid-cols="2"] .feed-grid .grid-tiles { grid-template-columns: repeat(2, 1fr); }
-    body[data-grid-cols="3"] .feed-grid .grid-tiles { grid-template-columns: repeat(3, 1fr); }
-    body[data-grid-cols="4"] .feed-grid .grid-tiles { grid-template-columns: repeat(4, 1fr); }
+    body.on-home[data-grid-cols="2"] .grid-tiles { grid-template-columns: repeat(2, 1fr); }
+    body.on-home[data-grid-cols="3"] .grid-tiles { grid-template-columns: repeat(3, 1fr); }
+    body.on-home[data-grid-cols="4"] .grid-tiles { grid-template-columns: repeat(4, 1fr); }
 }
 
@@ -3263,5 +2440,5 @@
 
 /* Masthead: compacter */
-.masthead { padding: calc(.55rem + var(--ios-safe-top, 0px)) 0 .55rem; }
+.masthead { padding: calc(.55rem + var(--safe-top)) 0 .55rem; }
 .masthead-inner { gap: 1rem; }
 .site-title { font-size: 1.25rem; }
@@ -3291,5 +2468,5 @@
 
 /* Site footer compacter */
-.site-footer { padding: 1.25rem 0 calc(1.25rem + env(safe-area-inset-bottom, 0px)); margin-top: 2rem; font-size: .78rem; }
+.site-footer { padding: 1.25rem 0 calc(1.25rem + var(--safe-bot)); margin-top: 2rem; font-size: .78rem; }
 .site-footer-inner { gap: .75rem; }
 
@@ -3301,6 +2478,5 @@
 .profile-bio { font-size: .85rem; margin-top: .15rem; }
 .profile-links { margin-top: .5rem; gap: .4rem; }
-.profile-links a,
-.profile-links .profile-fedi-link { width: 32px; height: 32px; }
+.profile-links a { width: 32px; height: 32px; }
 
 @media (min-width: 768px) {
@@ -3525,21 +2701,4 @@
     border-color: var(--accent, #c2410c);
     background: color-mix(in srgb, var(--accent, #c2410c) 5%, var(--paper-2, transparent));
-}
-/* Korte highlight wanneer je via de mini-speler naar deze track springt. */
-.post-audio-track.pat-flash,
-.post-album-track-compact.pat-flash {
-    animation: patFlash 1.6s ease-out;
-    border-radius: 8px;
-}
-/* Blijvende highlight op de track die NU actief is in de speler. */
-.post-audio-track.pat-playing,
-.post-album-track-compact.pat-playing {
-    background: color-mix(in srgb, var(--accent, #c2410c) 12%, var(--paper-2, transparent));
-    box-shadow: inset 3px 0 0 0 var(--accent, #c2410c);
-    border-radius: 8px;
-}
-@keyframes patFlash {
-    0%, 30% { border-color: var(--accent, #c2410c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #c2410c) 35%, transparent); }
-    100% { box-shadow: 0 0 0 0 transparent; }
 }
 .post-audio-track .pat-play {
@@ -4983,278 +4142,2 @@
     }
 }
-
-/* === Fediverse interactions — inherits the native .comment styling === */
-.post-fediverse .fedi-stats { display: flex; gap: .45rem; margin: .35rem 0 1.5rem; align-items: center; }
-.post-fediverse .fedi-stat {
-  display: inline-flex; align-items: center; gap: .4rem;
-  padding: .32rem .7rem; border-radius: 999px; font-size: .85rem; line-height: 1;
-  font-variant-numeric: tabular-nums;
-  color: var(--ink-muted); background: transparent;
-  border: 1px solid var(--rule);
-}
-.post-fediverse .fedi-stat svg { width: 15px; height: 15px; }
-.post-fediverse .fedi-stat-btn { cursor: pointer; font: inherit; line-height: 1; transition: color .15s, border-color .15s, background .15s; }
-.post-fediverse .fedi-stat-btn:hover { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 55%, var(--rule)); background: var(--accent-tint); }
-.post-fediverse .fedi-stat-btn:hover svg { fill: color-mix(in srgb, var(--accent) 22%, transparent); }
-.post-fediverse .fedi-handle { color: var(--ink-soft, #888); font-size: .82rem; }
-.post-fediverse .comment-content p { margin: .2rem 0; }
-.post-fediverse .comment-content p:first-child { margin-top: 0; }
-.post-fediverse .comment-content p:last-child { margin-bottom: 0; }
-/* reply toggle: a plain "Reageer" action like the comment buttons (no triangle) */
-.post-fediverse .fedi-reply-toggle { list-style: none; cursor: pointer; display: inline-block; }
-.post-fediverse .fedi-reply-toggle::-webkit-details-marker { display: none; }
-.post-fediverse details[open] .fedi-reply-toggle { margin-bottom: .5rem; }
-
-/* Reply-via-fediverse CTA on post pages */
-.post-fediverse-cta { margin: 1.75rem 0; }
-.fedi-remote-reply-btn { display: inline-flex; align-items: center; gap: .45rem; }
-.post-fediverse-cta .fedi-remote-reply-btn {
-  border: 1px solid color-mix(in srgb, var(--accent, #888) 55%, transparent);
-  background: color-mix(in srgb, var(--accent, #888) 10%, transparent);
-  color: var(--ink, inherit); border-radius: 999px; padding: .5rem 1.05rem;
-  font-weight: 600; cursor: pointer; transition: background .15s ease;
-}
-.post-fediverse-cta .fedi-remote-reply-btn:hover { background: color-mix(in srgb, var(--accent, #888) 22%, transparent); }
-.post-fediverse-cta .fedi-remote-reply-btn svg { color: var(--accent, currentColor); }
-.post-fediverse .comment-actions .fedi-remote-reply-btn { gap: .3rem; }
-
-/* Inline "reply via fediverse" address form (replaces the browser prompt) */
-.fedi-remote-form {
-  position: absolute; z-index: 60; display: inline-flex; align-items: center; gap: .4rem;
-  padding: .4rem; border-radius: 999px;
-  background: var(--paper-2, var(--paper, #fff));
-  border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);
-  box-shadow: 0 10px 30px rgba(0,0,0,.28);
-}
-.fedi-remote-form input {
-  height: 2.2rem; padding: 0 .9rem; border-radius: 999px; font: inherit; min-width: 230px;
-  border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent);
-  background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, #000);
-}
-.fedi-remote-form input::placeholder { color: var(--ink-soft, #999); }
-.fedi-remote-form input:focus { outline: none; border-color: color-mix(in srgb, var(--accent, #888) 70%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #888) 22%, transparent); }
-.fedi-remote-form .fedi-remote-go {
-  height: 2.2rem; min-width: 2.2rem; padding: 0 .85rem; border-radius: 999px;
-  display: inline-flex; align-items: center; justify-content: center; font-size: 1rem;
-}
-.fedi-remote-form .fedi-remote-cancel { background: none; border: none; color: var(--ink-soft, #888); cursor: pointer; font-size: 1.1rem; line-height: 1; padding: .25rem .35rem; }
-.fedi-remote-form .fedi-remote-cancel:hover { color: var(--ink, #000); }
-
-/* ── NSFW / sensitive content — blur + click to reveal ───────────────── */
-.nsfw-media { position: relative; }
-.nsfw-veil {
-  position: absolute; inset: 0; z-index: 4; cursor: pointer; border-radius: inherit;
-  display: flex; flex-direction: column; align-items: center; justify-content: center;
-  gap: .5rem; padding: .75rem; text-align: center;
-  background: color-mix(in srgb, var(--paper, #14141a) 50%, transparent);
-  backdrop-filter: blur(22px) saturate(.6); -webkit-backdrop-filter: blur(22px) saturate(.6);
-}
-.nsfw-media.is-shown > .nsfw-veil { display: none; }
-.nsfw-veil-label { font-weight: 700; font-size: .82rem; color: var(--ink, #f3f1ea); text-shadow: 0 1px 2px rgba(0,0,0,.5); }
-.nsfw-veil-btn {
-  font-size: .72rem; font-weight: 600; padding: .3rem .8rem; border-radius: 999px;
-  border: 1px solid color-mix(in srgb, var(--ink, #fff) 55%, transparent);
-  background: color-mix(in srgb, var(--paper, #000) 35%, transparent); color: var(--ink, #f3f1ea);
-}
-/* Post page: blur cover + body behind a content-warning banner until revealed */
-.nsfw-gate .post-cover,
-.nsfw-gate .post-content { filter: blur(26px); pointer-events: none; user-select: none; transition: filter .2s ease; }
-.nsfw-gate.is-shown .post-cover,
-.nsfw-gate.is-shown .post-content { filter: none; pointer-events: auto; user-select: auto; }
-.nsfw-banner {
-  display: flex; align-items: center; gap: .7rem; flex-wrap: wrap; margin: 0 0 1.5rem;
-  padding: .8rem 1rem; border-radius: 12px;
-  border: 1px solid color-mix(in srgb, var(--accent, #e8b04b) 45%, transparent);
-  background: color-mix(in srgb, var(--accent, #e8b04b) 9%, transparent);
-}
-.nsfw-gate.is-shown .nsfw-banner { display: none; }
-.nsfw-banner-icon { font-size: 1.2rem; }
-.nsfw-banner-text { font-weight: 600; font-size: .92rem; color: var(--ink); }
-.nsfw-banner-btn {
-  margin-left: auto; cursor: pointer; font: inherit; font-size: .82rem; font-weight: 600;
-  padding: .4rem 1rem; border-radius: 999px; border: 0;
-  background: var(--accent, #e8b04b); color: #1a1205;
-}
-
-/* ── Het bandje (mixtape) ──────────────────────────────────────────────
-   Een cassette, getekend als SVG op de echte maat: 100,4 x 63,8 mm, dus een
-   viewBox van 314x200. De vorige versie tekende twee schijfjes met CSS; dit is
-   een replica -- behuizing, labelvlak, venster, twee spoelen met tandjes, de
-   schroefjes in de hoeken en de openingen voor de kop en de capstans onderin.
-
-   Alle kleuren komen uit de bestaande variabelen, dus licht, donker en het
-   accent van de site volgen vanzelf. */
-.post-tape {
-    position: relative;
-    border: 1px solid var(--rule, rgba(0,0,0,.1));
-    border-radius: 14px;
-    overflow: hidden;
-    margin: 1.25rem 0;
-    background: var(--paper-2, color-mix(in srgb, var(--ink) 3%, transparent));
-}
-
-.tape-shell { position: relative; padding: .8rem .8rem .3rem; }
-.tape-svg { display: block; width: 100%; height: auto; }
-
-/* De behuizing. Twee lagen zodat er een randje omheen zit, zoals bij een echt
-   doosje waar de bovenschaal iets uitsteekt. */
-.tape-body { fill: color-mix(in srgb, var(--ink) 82%, var(--paper, #fff)); }
-.tape-body-inner { fill: color-mix(in srgb, var(--ink) 70%, var(--paper, #fff)); }
-.tape-labelplate { fill: color-mix(in srgb, var(--paper, #fff) 92%, var(--accent)); }
-.tape-glass {
-    fill: color-mix(in srgb, var(--ink) 88%, transparent);
-    stroke: color-mix(in srgb, var(--paper, #fff) 25%, transparent);
-    stroke-width: 1.5;
-}
-.tape-pack { fill: color-mix(in srgb, var(--ink) 55%, var(--paper, #fff)); }
-.tape-ribbon { fill: color-mix(in srgb, var(--ink) 45%, var(--paper, #fff)); }
-.tape-hub { fill: var(--paper, #fff); }
-.tape-tooth { fill: color-mix(in srgb, var(--ink) 60%, var(--paper, #fff)); }
-.tape-screw { fill: color-mix(in srgb, var(--ink) 55%, var(--paper, #fff)); }
-.tape-slot { fill: color-mix(in srgb, var(--ink) 92%, transparent); }
-.tape-slot--capstan { fill: color-mix(in srgb, var(--ink) 96%, transparent); }
-
-/* Draaien. De tandjes zitten in dezelfde groep als de naaf, dus de hele spoel
-   draait als een geheel -- daar gaat het om, want aan die tandjes zie je pas
-   DAT hij draait. Het label blijft stil, anders wordt de titel onleesbaar. */
-.post-tape.is-playing .tape-reel { animation: tape-spin 3s linear infinite; }
-.post-tape.is-playing .tape-reel--right { animation-duration: 2.2s; }
-/* Spoelen: veel sneller, en achteruit ook echt achteruit. */
-.post-tape.is-winding .tape-reel { animation-duration: .45s; }
-.post-tape.is-winding-back .tape-reel { animation-direction: reverse; }
-@keyframes tape-spin { to { transform: rotate(360deg); } }
-@media (prefers-reduced-motion: reduce) {
-    .post-tape.is-playing .tape-reel,
-    .post-tape.is-winding .tape-reel { animation: none; }
-}
-
-/* De tekst staat OP het labelvlak van de cassette. Percentages volgen de
-   viewBox, dus het blijft kloppen op elke breedte. */
-.tape-label {
-    position: absolute;
-    left: 8.4%; right: 8.4%;
-    top: calc(.8rem + 11%);
-    height: 31%;
-    padding: .1rem .4rem;
-    color: color-mix(in srgb, var(--ink) 92%, transparent);
-    overflow: hidden;
-}
-.tape-kind {
-    margin: 0;
-    font-size: clamp(.55rem, 1.6vw, .68rem);
-    letter-spacing: .14em;
-    text-transform: uppercase;
-    opacity: .7;
-}
-.tape-title {
-    margin: 0;
-    font-size: clamp(.85rem, 2.6vw, 1.1rem);
-    line-height: 1.15;
-    white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
-}
-.tape-artist { margin: 0; font-size: clamp(.65rem, 1.9vw, .8rem); opacity: .8; }
-.tape-meta { margin: 0; font-size: clamp(.6rem, 1.7vw, .72rem); opacity: .6; }
-
-/* De knoppen: terugspoelen, spelen, vooruitspoelen. Meer heeft een cassette
-   niet, en dat is de bedoeling -- er is geen zoekbalk omdat je op een bandje
-   niet kunt zoeken, alleen spoelen. */
-.tape-controls {
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    gap: .6rem;
-    padding: .5rem 0 .6rem;
-}
-.tape-btn {
-    display: grid;
-    place-items: center;
-    width: 40px; height: 40px;
-    border-radius: 50%;
-    border: 1px solid var(--rule, rgba(0,0,0,.15));
-    background: var(--paper, #fff);
-    color: var(--ink);
-    cursor: pointer;
-    padding: 0;
-    touch-action: manipulation;   /* vasthouden mag geen zoom of scroll worden */
-    user-select: none;
-}
-.tape-btn svg { width: 18px; height: 18px; }
-.tape-btn:hover { border-color: var(--accent); color: var(--accent); }
-.tape-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
-.tape-btn--play {
-    width: 52px; height: 52px;
-    background: var(--accent);
-    border-color: var(--accent);
-    color: var(--paper, #fff);
-}
-.tape-btn--play svg { width: 22px; height: 22px; }
-.post-tape.is-winding .tape-btn--back,
-.post-tape.is-winding .tape-btn--fwd { border-color: var(--accent); color: var(--accent); }
-
-.tape-now {
-    margin: 0;
-    padding: 0 .8rem .4rem;
-    text-align: center;
-    font-size: .8rem;
-    opacity: .8;
-    min-height: 1.2em;
-}
-
-/* De nummers staan er als INHOUD, niet als knoppen. Geen cursor, geen hover,
-   geen aanwijzing dat je erop kunt drukken: op een cassette kun je dat niet. */
-.tape-tracks {
-    margin: 0;
-    padding: 0 .8rem .8rem;
-    list-style: none;
-    counter-reset: tape;
-    display: grid;
-    gap: .1rem;
-}
-.tape-track {
-    counter-increment: tape;
-    display: flex;
-    gap: .5rem;
-    align-items: baseline;
-    font-size: .85rem;
-    padding: .18rem .3rem;
-    border-radius: 6px;
-    opacity: .75;
-}
-.tape-track::before {
-    content: counter(tape) ".";
-    opacity: .55;
-    min-width: 1.4em;
-    font-variant-numeric: tabular-nums;
-}
-.tape-track-title { flex: 1; }
-.tape-track-dur { opacity: .6; font-variant-numeric: tabular-nums; }
-.tape-track.is-current {
-    opacity: 1;
-    background: color-mix(in srgb, var(--accent) 12%, transparent);
-}
-
-
-/* ── De speler in bandmodus ────────────────────────────────────────────
-   Zelfde speler, ander gedrag: op een cassette kies je geen nummer. De lijst
-   blijft staan zodat je ziet wat erop staat, maar zonder de opmaak die belooft
-   dat je erin kunt prikken. */
-.audio-sheet-queue-list.is-tape .audio-sheet-queue-item {
-    cursor: default;
-    pointer-events: none;
-}
-.audio-sheet-queue-list.is-tape .audio-sheet-queue-item:hover {
-    background: none;
-}
-/* Het lopende nummer blijft wel gemarkeerd -- dat is de enige plek waar je op
-   een bandje leest waar je bent. */
-.audio-sheet-queue-list.is-tape .audio-sheet-queue-item.is-current {
-    opacity: 1;
-}
-/* Spoelknoppen mogen niet als "volgende" voelen: vasthouden hoort geen tekst te
-   selecteren of de pagina te laten zoomen. */
-.audio-btn.is-wind {
-    touch-action: manipulation;
-    user-select: none;
-}
Index: src/assets/fonts/OFL.txt
===================================================================
--- src/assets/fonts/OFL.txt	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,107 +1,0 @@
-The fonts bundled in this directory are licensed under the SIL Open Font
-License, Version 1.1. The license is reproduced in full below.
-
-Bundled fonts and their copyright holders:
-
-  Fraunces
-  Copyright 2019 The Fraunces Project Authors
-  (https://github.com/undercasetype/Fraunces)
-
-  Plus Jakarta Sans
-  Copyright 2020 The Plus Jakarta Sans Project Authors
-  (https://github.com/tokotype/PlusJakartaSans)
-
-  Literata
-  Copyright 2018 The Literata Project Authors
-  (https://github.com/googlefonts/literata)
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-This license is copied below, and is also available with a FAQ at:
-https://openfontlicense.org
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded,
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply to any
-document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION & CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
Index: src/assets/img/cirkel-logo.svg
===================================================================
--- src/assets/img/cirkel-logo.svg	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,13 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" stroke="black" stroke-linecap="round" stroke-linejoin="round">
-  <defs>
-    <marker id="cf-a" viewBox="0 0 10 10" refX="6.5" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse">
-      <path d="M1.5 1.5 L8.5 5 L1.5 8.5 Z" fill="black" stroke="none"/>
-    </marker>
-  </defs>
-  <line x1="28.95" y1="43.5" x2="63.05" y2="26.5" stroke-width="2.6" marker-start="url(#cf-a)" marker-end="url(#cf-a)"/>
-  <line x1="72" y1="32" x2="72" y2="66" stroke-width="2.6" marker-end="url(#cf-a)"/>
-  <line x1="28.8" y1="52.7" x2="63.2" y2="71.3" stroke-width="2.6" marker-end="url(#cf-a)"/>
-  <circle cx="20" cy="48" r="7" stroke-width="2.4"/>
-  <circle cx="72" cy="22" r="7" stroke-width="2.4"/>
-  <circle cx="72" cy="76" r="7" stroke-width="2.4"/>
-</svg>
Index: src/assets/js/audio-player.js
===================================================================
--- src/assets/js/audio-player.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/assets/js/audio-player.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -1,4 +1,4 @@
 /**
- * Klonkt Audio Player — v9 mini-player + Spotify-style sheet.
+ * PrutCMS v10 Audio Player — v9 mini-player + Spotify-style sheet.
  *
  * Two surfaces:
@@ -13,5 +13,5 @@
  *  - body.has-audio-player adds bottom padding when player visible
  *  - body.audio-sheet-locked prevents body scroll when sheet open
- *  - Survives HTMX swaps + history-restores via event delegation on document.body
+ *  - Survives HTMX swaps via htmx:afterSettle re-attach
  *
  * Singleton — guards against double-init.
@@ -27,7 +27,4 @@
     vol:   '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zM16 8a5 5 0 010 8M19 5a9 9 0 010 14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
     mute:  '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3zM17 9l5 5M22 9l-5 5" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>',
-    // Dubbele driehoeken: het teken voor spoelen, niet voor overslaan.
-    rew:   '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 12l9-7v14zM2 12l9-7v14z" fill="currentColor"/></svg>',
-    ff:    '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 12L4 5v14zM22 12l-9-7v14z" fill="currentColor"/></svg>',
     musicNote: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 17V5l12-2v12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6" cy="17" r="3" fill="currentColor"/><circle cx="18" cy="15" r="3" fill="currentColor"/></svg>',
   };
@@ -131,8 +128,4 @@
   const prevBtn = $('audio-prev');
   const nextBtn = $('audio-next');
-  // De vier knoppen die in bandmodus van betekenis veranderen: vorige/volgende
-  // worden terugspoelen/vooruitspoelen. Ze staan hier bij elkaar zodat het
-  // omzetten op EEN plek gebeurt.
-  const SPOELKNOPPEN = ['audio-prev', 'audio-next', 'audio-sheet-prev', 'audio-sheet-next'];
   const muteBtn = $('audio-mute');
   const volumeSlider = $('audio-volume');
@@ -165,41 +158,4 @@
   let isPlaying = false;
   let albumName = '';
-  // BANDMODUS (Robins eis, 21-8): een mixtape is EEN object, geen wachtrij met
-  // nummers. De MSE-keten hieronder maakt van een wachtrij toch al een
-  // doorlopende tijdlijn -- "een trackwissel is een positie, geen omschakeling"
-  // -- dus een bandje is precies die tijdlijn, alleen anders getoond en anders
-  // bediend: je ziet de titel van het bandje, de teller loopt over het geheel,
-  // en spoelen gaat in seconden in plaats van per nummer.
-  let tapeMode = false;
-  // Playback pipeline. We fetch each track's bytes ourselves (X-Audio-Player
-  // gate; no plain media URL is ever exposed to the page) and feed them to the
-  // <audio> element through one of two engines:
-  //
-  //  1. MSE chain (Chrome/Firefox/Android): ONE MediaSource + SourceBuffer
-  //     ('audio/mpeg', sequence mode — every track is uniform transcoder mp3).
-  //     The next track's bytes are APPENDED into the same buffer, so the whole
-  //     queue is one continuous playback session. That is what keeps a
-  //     backgrounded tab/PWA playing across track changes: Chrome's background
-  //     media policy pauses NEW playback sessions started in the background
-  //     (the old per-track src-swap + load() + play()), but never interrupts a
-  //     continuing one. A track change becomes a timeline position, not a swap.
-  //  2. Blob fallback (iOS Safari — no MSE; or MSE failed at runtime): one
-  //     objectURL per track, the previous behaviour.
-  let currentObjectUrl = null;
-  // Monotonic load token: a fast prev/next can fire several loads before an
-  // earlier fetch resolves. Only the latest load may touch the audio pipeline.
-  let loadSeq = 0;
-  // Next-track prefetch (blob engine; the MSE engine appends ahead instead).
-  // Shape: { url, bytes } — bytes is null while the fetch is still in flight.
-  let preload = null;
-  // ── MSE chain state ──
-  const MSE_SUPPORTED = !!(window.MediaSource && MediaSource.isTypeSupported && MediaSource.isTypeSupported('audio/mpeg'));
-  let mseFailed = false;               // runtime bail → blob engine for the rest of this session
-  const useMse = () => MSE_SUPPORTED && !mseFailed;
-  let ms = null;                       // MediaSource
-  let sb = null;                       // SourceBuffer
-  let chain = [];                      // appended segments: { qIndex, start, end } (timeline seconds)
-  let chainFetching = false;           // a fetch+append for the NEXT track is in flight
-  let sbOps = Promise.resolve();       // serializes SourceBuffer operations
 
   // Hide initially
@@ -221,379 +177,5 @@
   }
 
-  // Fetch the track bytes (ArrayBuffer). The X-Audio-Player header +
-  // same-origin credentials get us past the stream route's access gate.
-  // Retries a few times with backoff: a single transient network blip used to
-  // bump the error counter and SKIP the song (auto-advance past it). Now one
-  // hiccup just costs a retry, and we only give up after genuinely failing.
-  async function fetchTrackBytes(url, attempts) {
-    attempts = attempts || 1;
-    let lastErr;
-    for (let i = 0; i < attempts; i++) {
-      try {
-        const r = await fetch(url, {
-          credentials: 'same-origin',
-          headers: { 'X-Audio-Player': '1' },
-        });
-        if (!r.ok) throw new Error('HTTP ' + r.status);
-        return await r.arrayBuffer();
-      } catch (e) {
-        lastErr = e;
-        if (i < attempts - 1) {
-          await new Promise((res) => setTimeout(res, 350 * (i + 1)));
-        }
-      }
-    }
-    throw lastErr;
-  }
-
-  // Blob fallback engine: wrap the bytes in an objectURL and swap audio.src.
-  function applyBlobBytes(bytes, autoplay, mySeq) {
-    if (mySeq !== loadSeq) return;  // superseded
-    const objUrl = URL.createObjectURL(new Blob([bytes], { type: 'audio/mpeg' }));
-    root.classList.remove('audio-loading');
-    // Free the previously-playing track's blob — otherwise each track leaks a
-    // copy. Never the same handle as objUrl (createObjectURL is unique), so this
-    // can't revoke the source we're about to play.
-    if (currentObjectUrl && currentObjectUrl !== objUrl) {
-      try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
-    }
-    currentObjectUrl = objUrl;
-    // Clean transition: pause + load forces a reset of internal state after
-    // multiple src changes (prevents state corruption of the audio element).
-    try { audio.pause(); } catch (e) {}
-    audio.src = objUrl;
-    try { audio.load(); } catch (e) {}
-    if (autoplay) play();
-  }
-
-  function onLoadError(err, mySeq) {
-    if (mySeq !== loadSeq) return;  // superseded — ignore stale failure
-    root.classList.remove('audio-loading');
-    console.error('[pcms-audio] track load failed after retries', err);
-    // Genuine failure (after retries): bump the counter and auto-skip, but stop
-    // after 3 in a row so a fully-broken queue can't loop "next" forever.
-    consecutiveErrors++;
-    if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
-  }
-
-  // Discard any held/in-flight preload (plain bytes now — GC handles them).
-  function dropPreload() { preload = null; }
-
-  // Prefetch the *next* track's bytes in the background. Idempotent: re-calling
-  // while the same track is already cached / in flight is a no-op. Called from
-  // the `playing` event so the network is otherwise idle. Blob engine only —
-  // the MSE engine "preloads" by appending ahead (ensureNextAppended).
-  function preloadNext() {
-    if (queue.length < 2) return;
-    if (tapeMode && currentIndex >= queue.length - 1) return;   // einde band
-    const ni = (currentIndex + 1) % queue.length;
-    const t = queue[ni];
-    if (!t || !t.url) return;
-    if (preload && preload.url === t.url) return;  // already held or in flight
-    const marker = { url: t.url, bytes: null };
-    preload = marker;
-    fetchTrackBytes(t.url, 2).then((bytes) => {
-      // Only keep it if this is still the track we want next.
-      if (preload === marker) marker.bytes = bytes;
-    }).catch(() => { if (preload === marker) preload = null; });
-  }
-
-  // ============================================================
-  // 4a. MSE chain engine — one continuous playback session
-  // ============================================================
-  // All tracks are uniform transcoder mp3 (192kbps), so raw frames can be
-  // appended back-to-back into a single 'audio/mpeg' SourceBuffer (its
-  // byte-stream format generates continuous timestamps — sequence mode).
-  // Auto-advance = playback simply flowing into the next track's region.
-
-  // Strip ID3v2 (leading) / ID3v1 (trailing) tags: tag bytes between two
-  // appended tracks would glitch the MPEG frame parser.
-  function stripId3(buf) {
-    const u8 = new Uint8Array(buf);
-    let start = 0, end = u8.length;
-    if (end > 10 && u8[0] === 0x49 && u8[1] === 0x44 && u8[2] === 0x33) {  // "ID3"
-      const size = ((u8[6] & 0x7f) << 21) | ((u8[7] & 0x7f) << 14) | ((u8[8] & 0x7f) << 7) | (u8[9] & 0x7f);
-      const skip = 10 + size + ((u8[5] & 0x10) ? 10 : 0);  // +10 when a footer is flagged
-      if (skip < end) start = skip;
-    }
-    if (end - start > 128 && u8[end - 128] === 0x54 && u8[end - 127] === 0x41 && u8[end - 126] === 0x47) end -= 128;  // "TAG"
-    return (start === 0 && end === u8.length) ? buf : buf.slice(start, end);
-  }
-
-  function teardownChain() {
-    chain = [];
-    chainFetching = false;
-    sbOps = Promise.resolve();
-    sb = null;
-    ms = null;
-  }
-
-  // Serialize a SourceBuffer operation (append/remove): they throw if issued
-  // while the buffer is still updating, so everything funnels through a queue.
-  function sbRun(fn) {
-    const run = () => new Promise((resolve, reject) => {
-      if (!sb || !ms || ms.readyState !== 'open') return resolve();
-      const ok  = () => { cleanup(); resolve(); };
-      const err = (e) => { cleanup(); reject(e); };
-      function cleanup() { sb.removeEventListener('updateend', ok); sb.removeEventListener('error', err); }
-      sb.addEventListener('updateend', ok);
-      sb.addEventListener('error', err);
-      try { fn(); } catch (e) { cleanup(); reject(e); }
-    });
-    const p = sbOps.then(run, run);
-    sbOps = p.catch(() => {});
-    return p;
-  }
-
-  // Append one track's bytes as the next segment of the chain.
-  async function appendSegment(qIndex, bytes, mySeq) {
-    const clean = stripId3(bytes);
-    try {
-      await sbRun(() => sb.appendBuffer(clean));
-    } catch (e) {
-      if (e && e.name === 'QuotaExceededError' && chain.length > 1) {
-        // Evict already-played data and retry once.
-        const seg = currentSegment();
-        if (seg && seg.start > 1) {
-          await sbRun(() => sb.remove(0, seg.start - 0.5));
-          chain = chain.filter((s) => s.end > seg.start - 0.5);
-        }
-        await sbRun(() => sb.appendBuffer(clean));
-      } else {
-        throw e;
-      }
-    }
-    if (mySeq !== loadSeq || !sb) return;
-    const buffered = sb.buffered;
-    const chainEnd = buffered.length ? buffered.end(buffered.length - 1) : 0;
-    const start = chain.length ? chain[chain.length - 1].end : (buffered.length ? buffered.start(0) : 0);
-    chain.push({ qIndex, start, end: chainEnd });
-    // DE STREAM MOET DICHT ALS ER NIETS MEER KOMT, anders vuurt `ended` nooit.
-    // Bij een wachtrij van een was dat al zo. Een BANDJE heeft nu hetzelfde
-    // nodig: sinds hij niet meer rondloopt haakt de keten na het laatste nummer
-    // niets meer aan, en dan bleef de band aan het eind hangen -- de teller
-    // stilstaand op de laatste seconde, isPlaying() waar, en de spoelen
-    // draaiend. Gemeten op dev (22-8): 125,6 van 125,7 en daar bleef hij.
-    const laatsteVanDeBand = tapeMode && qIndex >= queue.length - 1;
-    if ((queue.length === 1 || laatsteVanDeBand) && ms && ms.readyState === 'open') {
-      // Single-track queue: close the stream so `ended` fires (which replays
-      // it, matching the old engine's behaviour). Bij een bandje stopt `ended`
-      // hem juist, want next() pauzeert daar aan het eind.
-      try { ms.endOfStream(); } catch (e) {}
-    }
-  }
-
-  // Keep exactly one full track appended ahead of the one playing.
-  function ensureNextAppended() {
-    if (!useMse() || !sb || !ms || ms.readyState !== 'open' || chainFetching) return;
-    if (queue.length < 2 || !chain.length) return;
-    const seg = currentSegment();
-    if (!seg || chain.length - 1 - chain.indexOf(seg) >= 1) return;  // already one ahead
-    // EEN BANDJE LOOPT NIET ROND (Robins eis, 22-8). Deze modulo is precies wat
-    // een cassette eindeloos maakte: na het laatste nummer hing hij nummer een
-    // er weer achter, en omdat het een doorlopende keten is merk je dat niet
-    // eens als een trackwissel -- de band gaat gewoon door.
-    const volgendeInRij = chain[chain.length - 1].qIndex + 1;
-    if (tapeMode && volgendeInRij > queue.length - 1) return;   // einde band
-    const nextIdx = volgendeInRij % queue.length;
-    const t = queue[nextIdx];
-    if (!t || !t.url) return;
-    chainFetching = true;
-    const mySeq = loadSeq;
-    const bytesP = (preload && preload.url === t.url && preload.bytes)
-      ? Promise.resolve(preload.bytes)
-      : fetchTrackBytes(t.url, 2);
-    bytesP.then((bytes) => {
-      if (mySeq !== loadSeq) return;
-      if (preload && preload.url === t.url) preload = null;
-      return appendSegment(nextIdx, bytes, mySeq);
-    }).catch((e) => {
-      console.warn('[pcms-audio] next-track append failed', e);
-    }).finally(() => { chainFetching = false; });
-  }
-
-  // Drop played-out data so the buffer holds ~2 tracks at most.
-  function pruneBuffer(curSeg) {
-    if (!useMse() || !sb || !ms || ms.readyState !== 'open') return;
-    const cut = curSeg.start - 0.5;
-    if (cut <= 1) return;
-    sbRun(() => sb.remove(0, cut)).catch(() => {});
-    chain = chain.filter((s) => s.end > cut);
-  }
-
-  function currentSegment() {
-    const t = audio.currentTime || 0;
-    for (let i = 0; i < chain.length; i++) if (t < chain[i].end - 0.05) return chain[i];
-    return chain[chain.length - 1] || null;
-  }
-
-  // Playback flowed across a track boundary (the gapless auto-advance):
-  // update chrome/metadata, top the buffer up, evict what's been played.
-  function maybeCrossBoundary() {
-    const seg = currentSegment();
-    if (!seg || seg.qIndex === currentIndex) return;
-    currentIndex = seg.qIndex;
-    const t = queue[currentIndex];
-    if (t) {
-      console.log('[pcms-audio] gapless auto-advance →', t.title);
-      updateTrackChrome(t);
-    }
-    ensureNextAppended();
-    pruneBuffer(seg);
-    updatePositionState();
-    savePlayerState();
-  }
-
-  // Start a fresh chain at queue[index]. Manual actions only (start/jump/
-  // prev/next/restore) — those happen in the foreground, where starting a
-  // new playback session is allowed.
-  function chainStart(index, autoplay, mySeq, bytes) {
-    teardownChain();
-    ms = new MediaSource();
-    const msUrl = URL.createObjectURL(ms);
-    if (currentObjectUrl && currentObjectUrl !== msUrl) {
-      try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
-    }
-    currentObjectUrl = msUrl;
-    try { audio.pause(); } catch (e) {}
-    audio.src = msUrl;
-    try { audio.load(); } catch (e) {}
-    const bailToBlob = (e) => {
-      console.warn('[pcms-audio] MSE unavailable, using blob playback', e);
-      mseFailed = true;
-      teardownChain();
-      if (mySeq === loadSeq) applyBlobBytes(bytes, autoplay, mySeq);
-    };
-    ms.addEventListener('sourceopen', () => {
-      if (mySeq !== loadSeq || !ms) return;
-      try {
-        sb = ms.addSourceBuffer('audio/mpeg');
-      } catch (e) { return bailToBlob(e); }
-      appendSegment(index, bytes, mySeq).then(() => {
-        if (mySeq !== loadSeq) return;
-        // Session-restore: land at the saved in-track position.
-        if (pendingSeek > 0 && chain.length) {
-          const seg = chain[0];
-          try { audio.currentTime = Math.min(pendingSeek, (seg.end - seg.start) - 0.25); } catch (e) {}
-          pendingSeek = 0;
-        }
-        ensureNextAppended();
-      }).catch(bailToBlob);
-    }, { once: true });
-    if (autoplay) play();
-  }
-
-  // Current position/duration in TRACK coordinates (the MSE timeline is the
-  // whole chain; the UI always shows the single playing track).
-  // De positie BINNEN het huidige nummer. Apart van displayTimes(), want die
-  // geeft in bandmodus de teller over de hele band -- en het opslaan van de
-  // sessie heeft juist de trackpositie nodig: bij het herstellen begint de
-  // keten opnieuw en start dit nummer weer op nul.
-  function trackTijd() {
-    if (useMse() && chain.length) {
-      const seg = currentSegment();
-      if (seg) return { cur: Math.max(0, (audio.currentTime || 0) - seg.start), dur: seg.end - seg.start };
-    }
-    return { cur: audio.currentTime || 0, dur: audio.duration };
-  }
-
-  // De lengte van de hele band, uit de bekende trackduren. Null zodra er van
-  // een nummer geen duur bekend is: een som met gaten is een verzonnen getal,
-  // en dan valt de teller liever terug op wat hij wel zeker weet.
-  function bandDuur() {
-    if (!queue.length) return null;
-    let som = 0;
-    for (const t of queue) {
-      const d = Number(t && t.duration) || 0;
-      if (d <= 0) return null;
-      som += d;
-    }
-    return som;
-  }
-
-  /** Hoeveel band ligt er voor nummer `i`. */
-  function bandOffset(i) {
-    let som = 0;
-    for (let n = 0; n < i && n < queue.length; n++) som += Number(queue[n].duration) || 0;
-    return som;
-  }
-
-  function displayTimes() {
-    // EEN BANDJE HEEFT EEN TELLER, geen nummerpositie. Hij telt door over de
-    // kant heen.
-    //
-    // Uit de trackduren en niet uit de keten, en dat verschil is zichtbaar: de
-    // keten bevat alleen wat gebufferd is, dus het totaal groeide mee tijdens
-    // het luisteren (2:05 met een nummer geladen, 4:07 met drie). En sprong je
-    // naar nummer drie, dan begon de keten daar opnieuw op nul en stond de
-    // teller weer aan het begin van de band.
-    if (tapeMode) {
-      const totaal = bandDuur();
-      if (totaal) return { cur: bandOffset(currentIndex) + (trackTijd().cur || 0), dur: totaal };
-      if (useMse() && chain.length) return { cur: audio.currentTime || 0, dur: chain[chain.length - 1].end };
-    }
-    return trackTijd();
-  }
-
-  // metaOnly: show the track in the UI but DON'T download its bytes yet.
-  // Used by the site pre-seed so opening a page doesn't auto-download audio;
-  // the blob is fetched lazily on the first play().
-  // Persistently mark the current track (stays highlighted as long as it's active).
-  function markPlaying(trackId) {
-    document.querySelectorAll('.pat-playing').forEach((e) => e.classList.remove('pat-playing'));
-    if (!trackId) return;
-    const el = document.getElementById('track-' + trackId);
-    if (el) el.classList.add('pat-playing');
-  }
-  // After an htmx navigation the post DOM is replaced → reapply the highlight.
-  document.body.addEventListener('htmx:afterSettle', () => {
-    const t = queue[currentIndex];
-    if (t) markPlaying(t.id);
-  });
-
-  // Media Session metadata (lock-screen / notification info + artwork). Set per
-  // track; the action handlers are wired once below. Keeping a live media session
-  // is what lets iOS continue a programmatic auto-advance play() instead of
-  // pausing it immediately.
-  function updateMediaMetadata(t) {
-    if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
-    try {
-      const art = [];
-      if (t && t.cover) {
-        let u = t.cover; try { u = new URL(t.cover, location.href).href; } catch (e) {}
-        art.push({ src: u, sizes: '512x512', type: '' });
-      }
-      navigator.mediaSession.metadata = new MediaMetadata({
-        title: (t && t.title) || 'Untitled',
-        artist: (t && t.artist) || '',
-        album: albumName || '',
-        artwork: art,
-      });
-    } catch (e) { /* non-fatal */ }
-  }
-
-  // All the visible per-track chrome: titles, covers, queue highlight, media
-  // session metadata. Called from loadTrack AND from the gapless boundary-cross.
-  function updateTrackChrome(t) {
-    // In bandmodus staat het BANDJE op de speler. Wat er op dit moment klinkt
-    // staat eronder, zoals een cassette een titel op het label heeft en de
-    // nummers op het doosje.
-    const hoofd = tapeMode ? (albumName || 'Mixtape') : (t.title || 'Untitled');
-    const onder = tapeMode ? (t.title || '') : (t.artist || '');
-    titleEl.textContent  = hoofd;
-    artistEl.textContent = onder;
-    sheetTitle.textContent  = hoofd;
-    sheetArtist.textContent = onder;
-    sheetAlbum.textContent  = albumName || '';
-    setCoverImage(cover,      t.cover);
-    setCoverImage(sheetCover, t.cover);
-    root.classList.remove('audio-player-hidden');
-    document.body.classList.add('has-audio-player');
-    renderQueue();
-    markPlaying(t.id);
-    updateMediaMetadata(t);
-  }
-
-  function loadTrack(index, autoplay, metaOnly) {
+  function loadTrack(index) {
     if (!queue[index]) {
       console.warn('[pcms-audio] loadTrack: no track at index', index);
@@ -606,85 +188,31 @@
       return;
     }
-    console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : '');
-    // Metadata + chrome update synchronously so the UI reacts instantly while
-    // the bytes download.
-    updateTrackChrome(t);
-
-    if (metaOnly) return;
-
-    const mySeq = ++loadSeq;
-
-    // Fast path: the bytes for this exact track were already prefetched while
-    // the previous track played → no gap, no fetch window.
-    let bytesP;
-    if (preload && preload.url === t.url && preload.bytes) {
-      bytesP = Promise.resolve(preload.bytes);
-      preload = null;
-    } else {
-      // Not preloaded (or still in flight) → drop any stale preload and fetch
-      // fresh, retrying transient failures before giving up.
-      dropPreload();
-      root.classList.add('audio-loading');
-      bytesP = fetchTrackBytes(t.url, 3);
-    }
-    bytesP.then((bytes) => {
-      if (mySeq !== loadSeq) return;
-      root.classList.remove('audio-loading');
-      if (useMse()) chainStart(index, autoplay, mySeq, bytes);
-      else applyBlobBytes(bytes, autoplay, mySeq);
-    }).catch((err) => onLoadError(err, mySeq));
-  }
-
-  // True when the viewport is in the mobile sheet-layout — matches the CSS
-  // breakpoint where .audio-sheet slides up full-width from the bottom
-  // (@media max-width:719.98px). On wider/desktop widths the sheet is a centered
-  // panel, so we do NOT auto-open it there.
-  // Three layouts (Robin 2026-06-15): phone (<768) = fullscreen sheet;
-  // tablet/car (768–1199) = large landscape full-player (tablet + car-mode);
-  // desktop (≥1200) = mini-player only, NO full player. matchMedia so this
-  // exactly follows the CSS breakpoints.
-  function playerTier() {
-    if (window.matchMedia('(min-width: 1200px)').matches) return 'desktop';
-    if (window.matchMedia('(min-width: 768px)').matches) return 'tablet';
-    return 'phone';
-  }
-  function hasFullPlayer() { return playerTier() !== 'desktop'; }
-  function isMobileView() { return playerTier() === 'phone'; }
+    console.log('[pcms-audio] loading', t.title, t.url);
+    audio.src = t.url;
+    titleEl.textContent  = t.title  || 'Untitled';
+    artistEl.textContent = t.artist || '';
+    sheetTitle.textContent  = t.title  || 'Untitled';
+    sheetArtist.textContent = t.artist || '';
+    sheetAlbum.textContent  = albumName || '';
+    setCoverImage(cover,      t.cover);
+    setCoverImage(sheetCover, t.cover);
+    root.classList.remove('audio-player-hidden');
+    document.body.classList.add('has-audio-player');
+    renderQueue();
+  }
 
   function setQueue(tracks, startIdx, opts) {
     queue = Array.isArray(tracks) ? tracks.slice() : [];
     albumName = (opts && opts.albumName) || '';
-    tapeMode = !!(opts && opts.asTape);
-    zetKnopStanden();
     if (!queue.length) return;
-    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
-    // Mobile: a track press from an album/playlist auto-opens the full
-    // now-playing sheet (Spotify-style) instead of just the thin mini-strip.
-    // Only here (setQueue = a fresh, user-initiated queue) —
-    // not on next/prev or the site pre-seed — so a sheet the user
-    // deliberately closed doesn't reappear by itself.
-    if (hasFullPlayer()) openSheet();
+    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0);
+    play();
   }
 
   function play() {
-    if (!audio.src) {
-      // Nothing fetched yet (pre-seed showed metadata only, or a load is still
-      // in flight). Kick off the blob load for the current track and autoplay.
-      if (queue[currentIndex]) loadTrack(currentIndex, true);
-      return;
-    }
+    if (!audio.src) return;
     const p = audio.play();
     if (p && typeof p.catch === 'function') {
-      p.catch((err) => {
-        console.warn('[pcms-audio] play() rejected:', err.name, err.message);
-        // Browser autoplay policy blocked it (typically after 3-4
-        // auto-plays on iOS Safari, or when the tab was temporarily inactive).
-        // Show a visual hint for the user to tap play.
-        if (err && err.name === 'NotAllowedError') {
-          root.classList.add('audio-needs-tap');
-          isPlaying = false;
-          root.classList.remove('is-playing');
-        }
-      });
+      p.catch((err) => console.warn('[pcms-audio] play() rejected', err));
     }
   }
@@ -693,22 +221,14 @@
   function next() {
     if (!queue.length) return;
-    // Aan het eind van een bandje: stoppen. `ended` roept deze functie aan, dus
-    // zonder deze tak begint de band na het laatste nummer weer vooraan.
-    if (tapeMode && currentIndex >= queue.length - 1) { pause(); return; }
-    loadTrack((currentIndex + 1) % queue.length, true);
+    loadTrack((currentIndex + 1) % queue.length);
+    play();
   }
   function prev() {
     if (!queue.length) return;
-    // En aan het begin ook niet omlopen. Terugspoelen voorbij het begin levert
-    // de kop van de band op, niet het laatste nummer.
-    if (tapeMode && currentIndex === 0) {
-      try { audio.currentTime = 0; } catch (e) { /* nog niets geladen */ }
-      return;
-    }
-    loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
+    loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1);
+    play();
   }
   function close() {
     pause();
-    mediaRegistry().release(registrySelf);
     root.classList.add('audio-player-hidden');
     document.body.classList.remove('has-audio-player');
@@ -716,12 +236,4 @@
     queue = [];
     albumName = '';
-    tapeMode = false;
-    zetKnopStanden();
-    loadSeq++;  // cancel any in-flight load
-    dropPreload();
-    teardownChain();
-    if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
-    currentObjectUrl = null;
-    try { audio.removeAttribute('src'); audio.load(); } catch (e) {}
   }
 
@@ -737,14 +249,10 @@
       sheetQueueList.appendChild(li);
     });
-    // OP EEN BANDJE KIES JE NIET. De lijst blijft staan -- je mag zien wat
-    // erop staat -- maar zonder klik en zonder de opmaak die belooft dat het
-    // kan. Dat is hetzelfde onderscheid als bij de cassette in de post.
-    sheetQueueList.classList.toggle('is-tape', tapeMode);
-    if (tapeMode) return;
     sheetQueueList.querySelectorAll('.audio-sheet-queue-item').forEach((li) => {
       li.addEventListener('click', () => {
         const idx = parseInt(li.dataset.idx, 10);
         if (!isNaN(idx) && idx !== currentIndex) {
-          loadTrack(idx, true);
+          loadTrack(idx);
+          play();
         }
       });
@@ -758,123 +266,24 @@
 
   // ============================================================
-  // 4b. Mutual exclusion — shared media registry (see embed-player.js).
-  // ============================================================
-  // All players (this site player + YouTube/SoundCloud/Spotify embeds)
-  // register themselves in window.pcmsMediaRegistry. Starting one pauses
-  // the previous. This is the precise replacement for the old focus/blur
-  // heuristic for embeds with a real JS API. (The blur fallback below stays
-  // for iframe-only embeds without an API: Bandcamp/Apple Music/Vimeo.)
-  function mediaRegistry() {
-    if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
-    const r = {
-      _active: null,
-      setActive(player) {
-        if (this._active && this._active !== player && this._active.pause) {
-          try { this._active.pause(); } catch (e) {}
-        }
-        this._active = player;
-      },
-      release(player) { if (this._active === player) this._active = null; },
-    };
-    window.pcmsMediaRegistry = r;
-    return r;
-  }
-  const registrySelf = { pause() { try { audio.pause(); } catch (e) {} } };
-
-  // ============================================================
   // 5. Audio element events → UI sync
   // ============================================================
-  // Error counter prevents an infinite loop when ALL tracks are broken.
-  let consecutiveErrors = 0;
-
-  audio.addEventListener('play',  () => {
-    isPlaying = true;
-    root.classList.add('is-playing');
-    root.classList.remove('audio-needs-tap');  // hide tap hint
-    mediaRegistry().setActive(registrySelf);   // pause any currently playing embeds
-    if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'playing'; } catch (e) {} }
-  });
-
-  // Media Session action handlers (wired once): lock-screen / headset / car
-  // controls, and — crucially — an active session so iOS keeps a programmatic
-  // auto-advance playing instead of pausing it the instant it starts.
-  if ('mediaSession' in navigator) {
-    const ms = navigator.mediaSession;
-    const wire = (action, fn) => { try { ms.setActionHandler(action, fn); } catch (e) { /* unsupported action */ } };
-    wire('play', () => play());
-    wire('pause', () => pause());
-    wire('previoustrack', () => prev());
-    wire('nexttrack', () => next());
-    wire('seekto', (e) => {
-      if (!e || e.seekTime == null) return;
-      // Lock-screen scrubber works in TRACK coordinates (positionState below).
-      if (useMse() && chain.length) {
-        const seg = currentSegment();
-        if (seg) { try { audio.currentTime = seg.start + Math.min(e.seekTime, seg.end - seg.start - 0.1); } catch (er) {} }
-        return;
-      }
-      if (audio.duration) { try { audio.currentTime = e.seekTime; } catch (er) {} }
-    });
-  }
-  // Lock-screen / notification scrubber: report per-track position, not the
-  // whole-chain timeline.
-  function updatePositionState() {
-    if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
-    try {
-      const dt = displayTimes();
-      if (!isFinite(dt.dur) || !dt.dur) return;
-      navigator.mediaSession.setPositionState({
-        duration: dt.dur,
-        playbackRate: audio.playbackRate || 1,
-        position: Math.min(dt.cur, dt.dur),
-      });
-    } catch (e) { /* non-fatal */ }
-  }
-  // Reset the error counter only on a REAL playback start (`playing`), not the
-  // eager `play` event. `play` fires before any network/decode error, so resetting
-  // there would prevent the 3-strikes stop from ever triggering on a broken
-  // track → infinite "next" loop. `playing` only fires when audio is actually playing.
-  audio.addEventListener('playing', () => {
-    consecutiveErrors = 0;
-    if (useMse()) ensureNextAppended(); else preloadNext();
-    updatePositionState();
-  });
-  audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); if ('mediaSession' in navigator) { try { navigator.mediaSession.playbackState = 'paused'; } catch (e) {} } });
+  audio.addEventListener('play',  () => { isPlaying = true;  root.classList.add('is-playing'); });
+  audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
   audio.addEventListener('ended', next);
   audio.addEventListener('error', (e) => {
     const code = audio.error ? audio.error.code : '?';
     console.error('[pcms-audio] playback error', code, audio.src, e);
-    if (useMse() && ms) {
-      // The MSE pipeline failed (decode/append) → permanently fall back to the
-      // blob engine for this session and retry the SAME track.
-      console.warn('[pcms-audio] MSE failed, falling back to blob playback');
-      mseFailed = true;
-      teardownChain();
-      if (queue[currentIndex]) loadTrack(currentIndex, true);
-      return;
-    }
-    consecutiveErrors++;
-    // On network/decode error: skip to next track instead of stalling.
-    // Max 3 consecutive errors before giving up (otherwise infinite loop).
-    if (consecutiveErrors < 3 && queue.length > 1) {
-      console.warn('[pcms-audio] auto-skip to next after error', consecutiveErrors);
-      setTimeout(next, 400);
-    }
   });
-  audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled at', audio.currentTime));
+  audio.addEventListener('stalled', () => console.warn('[pcms-audio] stalled'));
   audio.addEventListener('volumechange', () => { root.classList.toggle('is-muted', audio.muted || audio.volume === 0); });
   audio.addEventListener('timeupdate', () => {
-    // Gapless boundary: in MSE mode a track change is just the timeline
-    // flowing past a segment edge — detect it here and update the chrome.
-    if (useMse() && chain.length) maybeCrossBoundary();
-    const dt = displayTimes();
-    if (!dt.dur || isNaN(dt.dur) || !isFinite(dt.dur)) return;
-    const pct = (dt.cur / dt.dur) * 100;
+    if (!audio.duration || isNaN(audio.duration)) return;
+    const pct = (audio.currentTime / audio.duration) * 100;
     seekFill.style.width = pct + '%';
     sheetSeekFill.style.width = pct + '%';
-    currentEl.textContent  = formatTime(dt.cur);
-    totalEl.textContent    = formatTime(dt.dur);
-    sheetCurrent.textContent = formatTime(dt.cur);
-    sheetTotal.textContent   = formatTime(dt.dur);
+    currentEl.textContent  = formatTime(audio.currentTime);
+    totalEl.textContent    = formatTime(audio.duration);
+    sheetCurrent.textContent = formatTime(audio.currentTime);
+    sheetTotal.textContent   = formatTime(audio.duration);
   });
 
@@ -885,71 +294,9 @@
   }
 
-  /**
-   * Waar op de BAND ligt deze verhouding? Geeft het nummer en de positie erin.
-   *
-   * Dit is de hele truc van hele-band-seek. De balk toont al de hele band (de
-   * teller rekent met de trackduren), maar aanklikken werkte binnen het lopende
-   * nummer -- en de keten houdt maar een nummer vooruit, dus een balk die de
-   * hele band belooft reikte in werkelijkheid tot nummer twee. Dat is erger dan
-   * geen balk, want het ziet eruit alsof het werkt.
-   *
-   * Omrekenen kan alleen met de echte duren, en die hebben we sinds de teller
-   * uit bandDuur()/bandOffset() komt. Dezelfde omrekening werkt op BEIDE
-   * motoren: de blob-motor heeft helemaal geen doorlopende tijdlijn, dus daar
-   * is dit niet alleen de beste maar de enige manier.
-   */
-  function bandPositie(ratio) {
-    const totaal = bandDuur();
-    if (!totaal) return null;
-    const doel = Math.max(0, Math.min(ratio, 1)) * totaal;
-    for (let i = 0; i < queue.length; i++) {
-      const start = bandOffset(i);
-      const eind = start + (Number(queue[i].duration) || 0);
-      if (doel < eind || i === queue.length - 1) {
-        return { index: i, binnen: Math.max(0, Math.min(doel - start, (Number(queue[i].duration) || 0) - 0.25)) };
-      }
-    }
-    return null;
-  }
-
   function attachSeek(seekEl) {
     seekEl.addEventListener('click', (e) => {
+      if (!audio.duration) return;
       const rect = seekEl.getBoundingClientRect();
       const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
-
-      // BANDMODUS: de balk is de hele band, dus de klik ook.
-      const plek = tapeMode ? bandPositie(ratio) : null;
-      if (plek) {
-        if (plek.index === currentIndex) {
-          // Binnen het lopende nummer: gewoon verzetten, geen herlaadsprong.
-          if (useMse() && chain.length) {
-            const seg = currentSegment();
-            if (seg) { try { audio.currentTime = seg.start + plek.binnen; } catch (er) {} }
-          } else {
-            try { audio.currentTime = plek.binnen; } catch (er) {}
-          }
-          updatePositionState();
-          return;
-        }
-        // Een ander nummer: laden en er meteen in springen. pendingSeek is de
-        // bestaande weg daarvoor -- het sessieherstel doet precies dit -- en
-        // wordt toegepast zodra de metadata er is. Er valt een hoorbaar gat bij
-        // de sprong; dat hoort bij spoelen naar een plek die nog niet in de
-        // buffer zit, en is eerlijker dan een balk die daar niet komt.
-        pendingSeek = plek.binnen;
-        loadTrack(plek.index, !audio.paused);
-        return;
-      }
-
-      if (useMse() && chain.length) {
-        // Seek within the CURRENT track's segment of the chain timeline.
-        const seg = currentSegment();
-        if (seg) {
-          try { audio.currentTime = seg.start + ratio * (seg.end - seg.start); } catch (er) {}
-          updatePositionState();
-        }
-        return;
-      }
-      if (!audio.duration) return;
       audio.currentTime = ratio * audio.duration;
     });
@@ -958,19 +305,10 @@
   attachSeek(sheetSeek);
 
-  // Volume + mute persist across sessions/pages via localStorage.
-  const VOL_KEY = 'pcmsVolume', MUTE_KEY = 'pcmsMuted';
-  const saveVol = () => { try { localStorage.setItem(VOL_KEY, String(audio.volume)); localStorage.setItem(MUTE_KEY, audio.muted ? '1' : '0'); } catch (e) { /* private mode */ } };
-  let _initVol = parseFloat(localStorage.getItem(VOL_KEY));
-  if (!isFinite(_initVol) || _initVol < 0 || _initVol > 1) _initVol = 0.8;
-  audio.volume = _initVol;
-  volumeSlider.value = Math.round(_initVol * 100);
-  if (localStorage.getItem(MUTE_KEY) === '1') audio.muted = true;
-  root.classList.toggle('is-muted', audio.muted || audio.volume === 0);
+  audio.volume = 0.8;
   volumeSlider.addEventListener('input', () => {
     audio.volume = volumeSlider.value / 100;
     if (volumeSlider.value > 0) audio.muted = false;
-    saveVol();
   });
-  muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; saveVol(); });
+  muteBtn.addEventListener('click', () => { audio.muted = !audio.muted; });
 
   // ============================================================
@@ -978,61 +316,19 @@
   // ============================================================
   playBtn.addEventListener('click', togglePlay);
-  /**
-   * De vier knoppen in de juiste stand zetten. In bandmodus zijn het
-   * spoelknoppen: ander teken, ander woord, en vasthouden spoelt door.
-   */
-  function zetKnopStanden() {
-    for (const id of SPOELKNOPPEN) {
-      const b = $(id);
-      if (!b) continue;
-      const vooruit = id.endsWith('next');
-      b.innerHTML = tapeMode ? (vooruit ? SVG.ff : SVG.rew) : (vooruit ? SVG.next : SVG.prev);
-      const label = tapeMode ? (vooruit ? 'Vooruitspoelen' : 'Terugspoelen') : (vooruit ? 'Volgende' : 'Vorige');
-      b.setAttribute('aria-label', label);
-      b.setAttribute('title', label);
-      b.classList.toggle('is-wind', tapeMode);
-    }
-  }
-
-  // Een KLIK: een nummer verder, of in bandmodus een stukje spoelen. Dat laatste
-  // is er voor toetsenbord en schermlezer, want vasthouden is met een
-  // spatiebalk geen gebaar.
-  const TIK_SPOEL_S = 5;
-  prevBtn.addEventListener('click', () => (tapeMode ? seekBy(-TIK_SPOEL_S) : prev()));
-  nextBtn.addEventListener('click', () => (tapeMode ? seekBy(TIK_SPOEL_S) : next()));
+  prevBtn.addEventListener('click', prev);
+  nextBtn.addEventListener('click', next);
   sheetPlay.addEventListener('click', togglePlay);
-  sheetPrev.addEventListener('click', () => (tapeMode ? seekBy(-TIK_SPOEL_S) : prev()));
-  sheetNext.addEventListener('click', () => (tapeMode ? seekBy(TIK_SPOEL_S) : next()));
-
-  // VASTHOUDEN spoelt door. Op de knop zelf indrukken, maar loslaten op
-  // document: glijdt je vinger van de knop af, dan hoort de band te stoppen en
-  // niet door te blijven spoelen.
-  for (const id of SPOELKNOPPEN) {
-    const b = $(id);
-    if (!b) continue;
-    b.addEventListener('pointerdown', () => { if (tapeMode) startWind(id.endsWith('next') ? 1 : -1); });
-  }
-  document.addEventListener('pointerup', stopWind);
-  document.addEventListener('pointercancel', stopWind);
+  sheetPrev.addEventListener('click', prev);
+  sheetNext.addEventListener('click', next);
 
   // ============================================================
   // 7. Sheet expand/close + drag-down-to-close
   // ============================================================
-  // Back button closes the sheet on mobile: on open we push a history entry
-  // so the phone back button (popstate) closes the sheet first instead of
-  // leaving the page. We balance it on a UI-initiated close via history.back().
-  let sheetHistoryPushed = false;
-
   function openSheet() {
-    if (!hasFullPlayer()) return; // desktop (≥1200): no full player, mini-player only
-    if (sheet.classList.contains('is-open')) return;
     sheet.classList.add('is-open');
     sheet.setAttribute('aria-hidden', 'false');
     document.body.classList.add('audio-sheet-locked');
-    // Phone + tablet: push a history entry so the back button closes the sheet first.
-    try { history.pushState({ pcmsSheet: true }, ''); sheetHistoryPushed = true; } catch (e) {}
-  }
-  function closeSheet(fromPopstate) {
-    if (!sheet.classList.contains('is-open')) return;
+  }
+  function closeSheet() {
     sheet.classList.remove('is-open');
     sheet.setAttribute('aria-hidden', 'true');
@@ -1040,87 +336,10 @@
     sheetPanel.style.removeProperty('--pcms-drag-y');
     sheetBackdrop.style.removeProperty('--pcms-sheet-progress');
-    // UI close (X / swipe / backdrop / Esc): pop our own history entry so the
-    // next back button navigates normally. On a popstate close (back button itself)
-    // the entry is already popped.
-    const wasPushed = sheetHistoryPushed;
-    sheetHistoryPushed = false;
-    if (wasPushed && !fromPopstate) { try { history.back(); } catch (e) {} }
-  }
-  window.addEventListener('popstate', () => {
-    if (sheet.classList.contains('is-open')) closeSheet(true);
-  });
-  // Clicking the mini-player track info:
-  //  - DESKTOP (≥1200px): jump to the post the track came from (if known),
-  //    via htmx so audio keeps playing. No post known → fall back to the sheet.
-  //  - MOBILE/TABLET: always open the full now-playing sheet.
-  function scrollToTrack(trackId) {
-    if (!trackId) { window.scrollTo(0, 0); return; }
-    const el = document.getElementById('track-' + trackId);
-    if (!el) { window.scrollTo(0, 0); return; }
-    el.scrollIntoView({ block: 'center', behavior: 'smooth' });
-    el.classList.add('pat-flash');
-    setTimeout(() => el.classList.remove('pat-flash'), 1600);
-  }
-  function goToPost(url, trackId) {
-    const hash = trackId ? ('#track-' + trackId) : '';
-    if (window.htmx && url.charAt(0) === '/') {
-      try {
-        const p = window.htmx.ajax('GET', url, { target: '#pcms-main', swap: 'innerHTML' });
-        history.pushState({}, '', url + hash);
-        // Scroll to the track after the swap (small delay so the global
-        // afterSwap scroll-to-top runs first); fall back to top if not found.
-        const go = () => setTimeout(() => scrollToTrack(trackId), 60);
-        if (p && typeof p.then === 'function') p.then(go); else setTimeout(go, 150);
-        return;
-      } catch (e) { /* fall back to full navigation */ }
-    }
-    location.href = url + hash;
-  }
-  expandTrigger.addEventListener('click', () => {
-    const t = queue[currentIndex];
-    // Only on true desktop (≥1200, no full-player) do we jump to the post.
-    // Tablet + phone have a full-player → open it (same as mobile behaviour).
-    const jumpToPost = !hasFullPlayer();
-    if (jumpToPost && t) {
-      // 1) Track played from a post → we already know that URL.
-      if (t.postUrl) { goToPost(t.postUrl, t.id); return; }
-      // 2) Site-wide track (no postUrl) → look up the post via the track id.
-      if (t.id) {
-        fetch('/audio/track/' + encodeURIComponent(t.id) + '/post')
-          .then((r) => (r.ok ? r.json() : null))
-          .then((d) => { if (d && d.url) goToPost(d.url, t.id); else openSheet(); })
-          .catch(() => openSheet());
-        return;
-      }
-    }
-    openSheet();
-  });
-  sheetClose.addEventListener('click', () => closeSheet());
-  sheetBackdrop.addEventListener('click', () => closeSheet());
+  }
+  expandTrigger.addEventListener('click', openSheet);
+  sheetClose.addEventListener('click', closeSheet);
+  sheetBackdrop.addEventListener('click', closeSheet);
   document.addEventListener('keydown', (e) => {
     if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
-  });
-  // Resized to desktop width (≥1200) while the full player is open? Close it —
-  // the full player doesn't exist on desktop.
-  window.addEventListener('resize', () => {
-    if (!hasFullPlayer() && sheet.classList.contains('is-open')) closeSheet();
-  });
-
-  // Fallback for mutual exclusion. For YouTube/SoundCloud/Spotify embeds the
-  // registry already handles this precisely (real play events). But for
-  // iframe-only embeds WITHOUT a JS API (Bandcamp/Apple/Vimeo) and for the
-  // iframe FALLBACK (when an ad-blocker blocks the player API) there is no
-  // play event: we catch those via focus. User clicks such an iframe →
-  // window 'blur' → pause our player. (For API embeds this is at worst a
-  // harmless double-pause.)
-  window.addEventListener('blur', () => {
-    setTimeout(() => {
-      const el = document.activeElement;
-      // Only embed iframes (inside .folio-embed) pause the player — not a
-      // random iframe (captcha/ad/map) that happens to receive focus.
-      if (el && el.tagName === 'IFRAME' && el.closest('.folio-embed') && audio.src && !audio.paused) {
-        pause();
-      }
-    }, 0);
   });
 
@@ -1184,64 +403,55 @@
   // wrapper. For the other three the data is on the button itself. The
   // handler reads from button-first, falls back to wrapper.
-  // Event delegation on document.body instead of per-button listeners. This
-  // survives HTMX history-restores: the mobile back button (popstate) lets HTMX
-  // restore #pcms-main from its snapshot; a per-element `data-pcms-attached` flag
-  // would leave dead buttons (flag baked into the snapshot, listener gone). One
-  // delegated listener works regardless of how many times the DOM is (re)swapped.
-  // WELKE KNOPPEN DEZE SPELER BEDIENT. Let op: dit is een LIJST MET NAMEN, geen
-  // regel over data-attributen. Een nieuwe knop die keurig data-pcms-track-url
-  // en data-pcms-album-id draagt doet dus niets zolang hij hier niet bij staat.
-  // Precies daar liep de cassetteknop op vast (21-8): de opmaak klopte, de
-  // gegevens klopten, en er gebeurde niets.
-  const PLAY_SELECTOR =
-    '.post-audio-track .pat-play, .post-album-tracks .pat-row, .post-album-cover-btn, .post-album-playall, .tape-btn--play';
-  document.body.addEventListener('click', (e) => {
-    const btn = e.target.closest(PLAY_SELECTOR);
-    if (!btn) return;
-    e.preventDefault();
-    e.stopPropagation();
-    // The post you're playing FROM = the current page (embeds live in post content).
-    // Store it on the track(s) so the desktop mini-player can jump back to it —
-    // survives the sessionStorage resume as well.
-    const postUrl = location.pathname + location.search;
-    // Resolve metadata: button-first, then closest .post-audio-track wrapper
-    // (only inline single-track widgets put the data on the wrapper).
-    const wrapper = btn.closest('.post-audio-track');
-    const albumId   = btn.dataset.pcmsAlbumId  || (wrapper && wrapper.dataset.pcmsAlbumId);
-    const trackData = btn.dataset.pcmsTrack    || (wrapper && wrapper.dataset.pcmsTrack);
-    const trackUrl  = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
-    console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
-
-    if (albumId) {
-      const album = document.getElementById(albumId);
-      if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
-      try {
-        const tracks = JSON.parse(album.dataset.pcmsAlbum);
-        tracks.forEach((t) => { t.postUrl = postUrl; });
-        // Start at the clicked track if we know its URL, else start at 0
-        // (cover-btn and playall both want to start from the beginning).
-        const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
-        // Een mixtape gaat als EEN object de speler in: de titel van het bandje
-        // op de speler, de teller over de hele band, en spoelen in seconden.
-        // De soort staat al op het blok (data-pcms-album-kind), dus hier is het
-        // een doorgeefje en geen tweede plek die iets afleidt.
-        setQueue(tracks, startIdx >= 0 ? startIdx : 0, {
-          albumName: album.dataset.pcmsAlbumTitle || '',
-          asTape: album.dataset.pcmsAlbumKind === 'mixtape',
-        });
-      } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
-    } else if (trackData) {
-      try {
-        const t = JSON.parse(trackData);
-        t.postUrl = postUrl;
-        setQueue([t], 0);
-      } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
-    } else if (trackUrl) {
-      // Fallback: at minimum we have the signed URL
-      setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '', postUrl }], 0);
-    } else {
-      console.error('[pcms-audio] no track data or url on button or wrapper', btn);
-    }
-  });
+  function attachListeners() {
+    const playBtns = document.querySelectorAll(
+      '.post-audio-track .pat-play, ' +
+      '.post-album-tracks .pat-row, ' +
+      '.post-album-cover-btn, ' +
+      '.post-album-playall'
+    );
+    if (playBtns.length) console.log('[pcms-audio] attaching to', playBtns.length, 'play buttons');
+
+    playBtns.forEach((btn) => {
+      if (btn.dataset.pcmsAttached) return;
+      btn.dataset.pcmsAttached = '1';
+
+      btn.addEventListener('click', (e) => {
+        e.preventDefault();
+        e.stopPropagation();
+        // Resolve metadata: button-first, then closest .post-audio-track wrapper
+        // (only inline single-track widgets put the data on the wrapper).
+        const wrapper = btn.closest('.post-audio-track');
+        const albumId   = btn.dataset.pcmsAlbumId  || (wrapper && wrapper.dataset.pcmsAlbumId);
+        const trackData = btn.dataset.pcmsTrack    || (wrapper && wrapper.dataset.pcmsTrack);
+        const trackUrl  = btn.dataset.pcmsTrackUrl || (wrapper && wrapper.dataset.pcmsTrackUrl);
+        console.log('[pcms-audio] click', { btn: btn.className, albumId, trackUrl, hasTrackData: !!trackData });
+
+        if (albumId) {
+          const album = document.getElementById(albumId);
+          if (!album) { console.error('[pcms-audio] album not found:', albumId); return; }
+          try {
+            const tracks = JSON.parse(album.dataset.pcmsAlbum);
+            // Start at the clicked track if we know its URL, else start at 0
+            // (cover-btn and playall both want to start from the beginning).
+            const startIdx = trackUrl ? tracks.findIndex(t => t.url === trackUrl) : 0;
+            setQueue(tracks, startIdx >= 0 ? startIdx : 0, { albumName: album.dataset.pcmsAlbumTitle || '' });
+          } catch(err) { console.error('[pcms-audio] bad album JSON', err, album.dataset.pcmsAlbum); }
+        } else if (trackData) {
+          try {
+            const t = JSON.parse(trackData);
+            setQueue([t], 0);
+          } catch(err) { console.error('[pcms-audio] bad track JSON', err, trackData); }
+        } else if (trackUrl) {
+          // Fallback: at minimum we have the signed URL
+          setQueue([{ url: trackUrl, title: 'Track', artist: '', cover: '' }], 0);
+        } else {
+          console.error('[pcms-audio] no track data or url on button or wrapper', btn);
+        }
+      });
+    });
+  }
+
+  attachListeners();
+  document.body.addEventListener('htmx:afterSettle', attachListeners);
 
   // ============================================================
@@ -1283,94 +493,6 @@
   // 9. Public API
   // ============================================================
-  /**
-   * SPOELEN, in seconden over de hele band.
-   *
-   * Op de MSE-motor is dit precies wat een cassette doet: `audio.currentTime`
-   * IS de tijdlijn van de hele keten, dus over een nummergrens heen spoelen is
-   * gewoon doortellen. Geen trackwissel, geen nieuwe afspeelsessie.
-   *
-   * Op de blob-motor (iOS Safari, geen MSE) bestaat die doorlopende tijdlijn
-   * niet: daar is elk nummer een eigen bron. Spoelen loopt daar dus tot de rand
-   * van het huidige nummer en stapt dan naar de buur. Grover, maar het is
-   * eerlijker dan doen alsof de band doorloopt terwijl hij dat niet doet.
-   */
-  function seekBy(seconden) {
-    const d = Number(seconden) || 0;
-    if (!d || !audio) return;
-    if (useMse() && chain.length) {
-      const eind = chain[chain.length - 1].end;
-      const doel = Math.max(0, Math.min((audio.currentTime || 0) + d, Math.max(0, eind - 0.25)));
-      try { audio.currentTime = doel; } catch (e) { /* buffer nog niet zover */ }
-      updatePositionState();
-      return;
-    }
-    // Blob-motor: binnen het nummer blijven, en anders naar de buur.
-    const duur = audio.duration || 0;
-    const nu = audio.currentTime || 0;
-    if (duur && nu + d >= duur) {
-      // Vooruit voorbij het eind. In bandmodus stopt next() aan het eind van de
-      // band; daarbuiten loopt hij door naar het volgende nummer.
-      next();
-      return;
-    }
-    if (nu + d < 0) {
-      // TERUGSPOELEN VOORBIJ HET BEGIN moet in het vorige nummer landen aan het
-      // EIND, niet aan het begin -- dat is wat terugspoelen doet. Zonder dit
-      // sprong je bij elke druk naar de kop van het vorige nummer en kwam je
-      // nooit ergens in het midden uit.
-      if (!(tapeMode && currentIndex === 0)) pendingSeek = Number.MAX_SAFE_INTEGER;
-      prev();
-      return;
-    }
-    try { audio.currentTime = Math.max(0, nu + d); } catch (e) {}
-  }
-
-  /**
-   * SPOELEN, met de knop ingedrukt.
-   *
-   * Stond eerst in assets/js/mod/tape.js, want daar zat de cassette. Nu de
-   * speler zelf ook spoelknoppen krijgt zou dezelfde lus op twee plekken staan,
-   * en dan lopen ze uit elkaar zodra iemand aan de versnelling draait. Hier is
-   * de enige plek; de cassette op de pagina roept deze aan.
-   */
-  const WIND_START = 4;        // maal de normale snelheid bij het indrukken
-  const WIND_MAX = 16;
-  const WIND_VERSNELLING = 1.35;
-  const WIND_STAP_MS = 120;
-  let winder = null;
-
-  function stopWind() {
-    if (winder) { clearInterval(winder.timer); winder = null; }
-  }
-
-  function startWind(richting) {
-    stopWind();
-    const r = richting < 0 ? -1 : 1;
-    let snelheid = WIND_START;
-    let vorige = null;
-    let stil = 0;
-    const timer = setInterval(() => {
-      snelheid = Math.min(WIND_MAX, snelheid * WIND_VERSNELLING);
-      seekBy(r * snelheid * (WIND_STAP_MS / 1000));
-      // AAN DE KOP EN DE STAART STOPPEN. De positie wordt afgeklemd, dus daar
-      // gebeurt niets meer -- maar zonder deze controle blijft de knop malen en
-      // draaien de spoelen door alsof er nog band is.
-      const nu = displayTimes().cur;
-      if (vorige !== null && Math.abs(nu - vorige) < 0.05) {
-        if (++stil >= 2) { stopWind(); return; }
-      } else stil = 0;
-      vorige = nu;
-    }, WIND_STAP_MS);
-    winder = { timer, richting: r };
-  }
-
-  /** Waar staat de teller, over het hele bandje. */
-  function tapeTijden() { return displayTimes(); }
-
   window.pcmsAudioPlayer = {
-    setQueue, play, pause, next, prev, close, openSheet, closeSheet, seekBy, tapeTijden,
-    startWind, stopWind,
-    isTape: () => tapeMode,
-    isWinding: () => !!winder,
+    setQueue, play, pause, next, prev, close, openSheet, closeSheet,
     isPlaying: () => isPlaying,
     currentTrack: () => queue[currentIndex] || null,
@@ -1378,73 +500,14 @@
 
   // ============================================================
-  // 10. Session persistence — player "survives" across page navigations
-  // ============================================================
-  // An <audio> element doesn't survive a full page load (and cross-context
-  // navigation — e.g. to the headerless hub overview — is intentionally a
-  // full-nav). We save the session to sessionStorage and restore + resume it
-  // on the next page: the player comes back with the same track at the same
-  // position. In Chrome (high media engagement) it resumes immediately;
-  // if the browser blocks autoplay it waits at that position (one tap = play).
-  const PLAYER_STATE_KEY = 'pcms-player-state';
-  let pendingSeek = 0;
-  function savePlayerState() {
-    try {
-      if (!queue.length) { sessionStorage.removeItem(PLAYER_STATE_KEY); return; }
-      sessionStorage.setItem(PLAYER_STATE_KEY, JSON.stringify({
-        queue, currentIndex, albumName, tapeMode,
-        // In-TRACK position (the MSE timeline spans the whole chain; a restore
-        // starts a fresh chain where this track begins at 0).
-        time: trackTijd().cur || 0,
-        playing: !!audio.src && !audio.paused,
-      }));
-    } catch (e) {}
-  }
-  window.addEventListener('pagehide', savePlayerState);
-  window.addEventListener('beforeunload', savePlayerState);
-  audio.addEventListener('play',  savePlayerState);
-  audio.addEventListener('pause', savePlayerState);
-  audio.addEventListener('ended', savePlayerState);
-  setInterval(() => { if (audio.src && !audio.paused) savePlayerState(); }, 5000);
-  // Apply the restored position once track metadata is available.
-  audio.addEventListener('loadedmetadata', () => {
-    if (pendingSeek > 0 && isFinite(audio.duration) && audio.duration > 0) {
-      try { audio.currentTime = Math.min(pendingSeek, audio.duration - 0.25); } catch (e) {}
-      pendingSeek = 0;
-    }
-  });
-  function restorePlayerState() {
-    let s = null;
-    try { s = JSON.parse(sessionStorage.getItem(PLAYER_STATE_KEY) || 'null'); } catch (e) { return false; }
-    if (!s || !Array.isArray(s.queue) || !s.queue.length) return false;
-    queue = s.queue;
-    albumName = s.albumName || '';
-    tapeMode = !!s.tapeMode;
-    zetKnopStanden();
-    pendingSeek = s.time || 0;
-    const idx = Math.max(0, Math.min(s.currentIndex || 0, queue.length - 1));
-    // playing → fetch + (attempt to) resume; paused → meta-only.
-    loadTrack(idx, !!s.playing, !s.playing);
-    return true;
-  }
-
-  // ============================================================
-  // 11. Site-level pre-seed (window.PCMS_SITE_TRACKS)
-  // ============================================================
-  // An active session (restore) wins over the page seed, so music that is
-  // already playing continues instead of being replaced by the new page's tracks.
-  if (!restorePlayerState()) {
-    if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
-      queue = window.PCMS_SITE_TRACKS.map(t => ({
-        id:     t.id || null,
-        url:    t.media_url || t.url,
-        title:  t.title  || 'Untitled',
-        artist: t.artist || '',
-        cover:  t.cover_url || t.cover || '',
-      }));
-      // Only prime the queue — the player bar appears only on the first audio click
-      // (.post-audio-track or the mini-player play button calls setQueue/loadTrack,
-      // which shows the bar). No more pre-seed bar on page load.
-      currentIndex = 0;
-    }
+  // 10. Site-level pre-seed (window.PCMS_SITE_TRACKS)
+  // ============================================================
+  if (Array.isArray(window.PCMS_SITE_TRACKS) && window.PCMS_SITE_TRACKS.length) {
+    queue = window.PCMS_SITE_TRACKS.map(t => ({
+      url:    t.media_url || t.url,
+      title:  t.title  || 'Untitled',
+      artist: t.artist || '',
+      cover:  t.cover_url || t.cover || '',
+    }));
+    if (queue.length) loadTrack(0);
   }
 })();
Index: src/assets/js/embed-player.js
===================================================================
--- src/assets/js/embed-player.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,611 +1,0 @@
-/**
- * Klonkt Embed Player — custom, on-brand media embeds on top of the REAL
- * player APIs (YouTube IFrame API, SoundCloud Widget API, Spotify iFrame API).
- *
- * The server (AudioEmbedService) renders a placeholder per embed:
- *   <div class="folio-embed folio-embed--<provider> pcms-embed-card"
- *        data-embed-provider data-embed-ref data-embed-type data-embed-url></div>
- * This script wraps it in our own card (cover/poster + our play button +
- * progress bar in brand style) and controls the underlying player via the
- * platform API, so play/pause/progress are in OUR hands.
- *
- * Capability honesty:
- *  - youtube/soundcloud  → fully custom controls (native chrome hidden).
- *  - spotify             → controls + our frame around it; Spotify's own UI
- *                          stays inside (no alternative without Premium+OAuth).
- *
- * Mutual exclusion: every player (incl. the site audio player in audio-player.js)
- * registers itself in window.pcmsMediaRegistry. Starting one pauses the previous.
- * This replaces the old focus/blur heuristic with real play events.
- *
- * Singleton — guard against double-init (HTMX can reload scripts).
- */
-(function () {
-  if (window.pcmsEmbedPlayer) return;
-
-  // ============================================================
-  // 0. Shared playback registry (also used by audio-player.js)
-  // ============================================================
-  function registry() {
-    if (window.pcmsMediaRegistry) return window.pcmsMediaRegistry;
-    const r = {
-      _active: null,
-      // Mark `player` as the sole active one; pause the previous.
-      setActive(player) {
-        if (this._active && this._active !== player && this._active.pause) {
-          try { this._active.pause(); } catch (e) {}
-        }
-        this._active = player;
-      },
-      release(player) { if (this._active === player) this._active = null; },
-    };
-    window.pcmsMediaRegistry = r;
-    return r;
-  }
-
-  // ============================================================
-  // 1. Lazy script loaders — 1 promise per platform, shared across N embeds.
-  //    A platform script is only loaded when an embed from that platform
-  //    is actually started on the page.
-  // ============================================================
-  const scripts = {};
-  function loadScript(src) {
-    return new Promise((resolve, reject) => {
-      const s = document.createElement('script');
-      s.src = src; s.async = true;
-      s.onload = () => resolve();
-      s.onerror = () => reject(new Error('embed script failed: ' + src));
-      document.head.appendChild(s);
-    });
-  }
-  // Important: all loaders REJECT on script errors (e.g. an ad-blocker blocking
-  // the API script) and on timeout — so the caller can gracefully fall back to
-  // the plain platform iframe instead of hanging indefinitely.
-  const API_TIMEOUT = 8000;
-
-  // YouTube: global callback onYouTubeIframeAPIReady (once) → wrap in a promise.
-  function ytApi() {
-    if (scripts.yt) return scripts.yt;
-    scripts.yt = new Promise((resolve, reject) => {
-      if (window.YT && window.YT.Player) return resolve(window.YT);
-      const prev = window.onYouTubeIframeAPIReady;
-      window.onYouTubeIframeAPIReady = function () {
-        if (typeof prev === 'function') { try { prev(); } catch (e) {} }
-        resolve(window.YT);
-      };
-      loadScript('https://www.youtube.com/iframe_api').catch(reject);
-      setTimeout(() => reject(new Error('YT API timeout')), API_TIMEOUT);
-    });
-    return scripts.yt;
-  }
-  // SoundCloud: no global ready callback; resolve on script onload, then
-  // each widget waits for its own SC.Widget.Events.READY.
-  function scApi() {
-    if (scripts.sc) return scripts.sc;
-    scripts.sc = new Promise((resolve, reject) => {
-      if (window.SC && window.SC.Widget) return resolve(window.SC);
-      loadScript('https://w.soundcloud.com/player/api.js')
-        .then(() => resolve(window.SC)).catch(reject);
-      setTimeout(() => { (window.SC && window.SC.Widget) ? resolve(window.SC) : reject(new Error('SC API timeout')); }, API_TIMEOUT);
-    });
-    return scripts.sc;
-  }
-  // Spotify: global callback onSpotifyIframeApiReady(IFrameAPI) (once) → wrap in a promise.
-  function spotifyApi() {
-    if (scripts.sp) return scripts.sp;
-    scripts.sp = new Promise((resolve, reject) => {
-      if (window.__spotifyIframeApi) return resolve(window.__spotifyIframeApi);
-      window.onSpotifyIframeApiReady = function (IFrameAPI) {
-        window.__spotifyIframeApi = IFrameAPI;
-        resolve(IFrameAPI);
-      };
-      loadScript('https://open.spotify.com/embed/iframe-api/v1').catch(reject);
-      // Shorter than API_TIMEOUT: Spotify's bundle initialises quickly or not at all
-      // (CDN 503 / origin-gating). No need to wait 8 s before the iframe fallback.
-      setTimeout(() => reject(new Error('Spotify API timeout')), 4000);
-    });
-    return scripts.sp;
-  }
-
-  // Plain platform iframe as fallback when the JS API is blocked/unreachable.
-  // Ad-blockers generally let embed iframes through. Autoplay parameter
-  // is safe here because we're always in a user-gesture context (play click).
-  function fallbackIframe(provider, ref, url) {
-    if (provider === 'youtube') {
-      const src = ytEmbedSrc(ref, url, 'autoplay=1&rel=0');
-      if (src) return { src, ratio: true, fs: true };
-    }
-    if (provider === 'soundcloud') {
-      return { src: 'https://w.soundcloud.com/player/?url=' + encodeURIComponent(ref || url) + '&auto_play=true&visual=true&hide_related=true', h: '300px' };
-    }
-    if (provider === 'spotify') {
-      const m = (ref || '').match(/^spotify:(\w+):(\w+)$/);
-      return { src: m ? `https://open.spotify.com/embed/${m[1]}/${m[2]}` : (url || ''), h: '152px' };
-    }
-    return { src: url || '' };
-  }
-
-  // ============================================================
-  // 2. Helpers
-  // ============================================================
-  function fmt(sec) {
-    if (!sec || isNaN(sec) || sec < 0) return '0:00';
-    const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
-    return m + ':' + (s < 10 ? '0' : '') + s;
-  }
-  // Three ref shapes, the same ones AudioEmbedService.detectProvider produces
-  // and the same ones the Klonkt hub uses, so a ref travels between them
-  // unchanged:  "<video>" | "<video>?list=<L>" | "list:<L>"
-  //
-  // ytId answers only "which VIDEO", because that is what a poster thumbnail
-  // needs; for a bare playlist there is no video and it returns null.
-  function ytId(ref, url) {
-    const r = String(ref || '');
-    if (r.indexOf('list:') === 0) return null;              // a playlist has no single video
-    const bare = r.split('?list=')[0];
-    if (/^[A-Za-z0-9_-]{11}$/.test(bare)) return bare;
-    const m = (url || '').match(/(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|live\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
-    if (m) return m[1];
-    return null;  // no blind slice — an invalid ref returns nothing rather than a broken id
-  }
-  /** The playlist id of a ref (or of the URL it came from), else null. */
-  function ytList(ref, url) {
-    const r = String(ref || '');
-    if (r.indexOf('list:') === 0) return r.slice(5) || null;
-    const i = r.indexOf('?list=');
-    if (i > 0) return r.slice(i + 6) || null;
-    const m = (url || '').match(/[?&](?:amp;)?list=([A-Za-z0-9_-]{10,60})/);
-    return m ? m[1] : null;
-  }
-  /** The /embed/ URL for a ref. A bare playlist embeds as `videoseries`. */
-  function ytEmbedSrc(ref, url, query) {
-    const base = 'https://www.youtube-nocookie.com/embed/';
-    const id = ytId(ref, url);
-    const list = ytList(ref, url);
-    let src;
-    if (!id && list) src = base + 'videoseries?list=' + encodeURIComponent(list);
-    else if (id && list) src = base + encodeURIComponent(id) + '?list=' + encodeURIComponent(list);
-    else if (id) src = base + encodeURIComponent(id);
-    else return null;
-    return src + (src.indexOf('?') > 0 ? '&' : '?') + (query || '');
-  }
-  // Only allow http(s) as href (defense-in-depth against javascript:/data: URIs).
-  function safeHref(u) {
-    try { const p = new URL(u, location.href); return (p.protocol === 'http:' || p.protocol === 'https:') ? u : '#'; }
-    catch (e) { return '#'; }
-  }
-  const ICON = {
-    play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4l12 8-12 8z" fill="currentColor"/></svg>',
-    pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4h4v16H7zM13 4h4v16h-4z" fill="currentColor"/></svg>',
-    volume: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor"/><path d="M16 8a5 5 0 010 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
-    muted: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 9v6h4l5 5V4L7 9H3z" fill="currentColor"/><path d="M16 9l5 6M21 9l-5 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
-  };
-  const LABEL = { youtube: 'YouTube', soundcloud: 'SoundCloud', spotify: 'Spotify' };
-
-  // Touch / coarse-pointer (phones, most tablets): the custom JS-mounted card is
-  // fragile — Safari tracking-protection blocks the platform API scripts AND the
-  // poster thumbnails (i.ytimg.com) → a black/blank box. Render the plain, reliable
-  // platform iframe directly instead (same approach the News feed uses). The
-  // padding-ratio wrapper reserves height on every browser incl. old iOS Safari
-  // (no `aspect-ratio` dependency). Desktop keeps the rich custom card.
-  const IS_TOUCH = !!(window.matchMedia && window.matchMedia('(hover: none) and (pointer: coarse)').matches);
-
-  function mountPlain(el, provider, ref, url) {
-    el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider, 'pcms-embed-plain');
-    el.classList.remove('pcms-embed-loading');
-    let html = '';
-    if (provider === 'youtube') {
-      const src = ytEmbedSrc(ref, url, 'rel=0');
-      html = src
-        ? '<div class="pcms-embed-ratio"><iframe src="' + escAttr(src) + '" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>'
-        : '<a class="pcms-embed-plain-link" href="' + escAttr(safeHref(url)) + '" target="_blank" rel="noopener">YouTube</a>';
-    } else if (provider === 'soundcloud') {
-      html = '<iframe class="pcms-embed-plain-frame" style="height:166px" src="https://w.soundcloud.com/player/?url=' + encodeURIComponent(ref || url) + '&color=%23ff5500&visual=false" title="SoundCloud" loading="lazy" frameborder="0" allow="autoplay" scrolling="no"></iframe>';
-    } else if (provider === 'spotify') {
-      const m = (ref || '').match(/^spotify:(\w+):(\w+)$/);
-      const src = m ? 'https://open.spotify.com/embed/' + m[1] + '/' + m[2] : (url || '');
-      html = '<iframe class="pcms-embed-plain-frame" style="height:152px" src="' + escAttr(src) + '" title="Spotify" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>';
-    } else {
-      html = '<a class="pcms-embed-plain-link" href="' + escAttr(safeHref(url)) + '" target="_blank" rel="noopener">' + (LABEL[provider] || provider) + '</a>';
-    }
-    el.innerHTML = html;
-  }
-
-  // ============================================================
-  // 3. Card controller — builds the on-brand card + delegates to an adapter
-  // ============================================================
-  function buildCard(el) {
-    const provider = el.dataset.embedProvider;
-    const ref = el.dataset.embedRef || '';
-    const url = el.dataset.embedUrl || '';
-    if (!provider) return;
-    if (IS_TOUCH) { mountPlain(el, provider, ref, url); return; }
-
-    el.classList.add('pcms-embed-card', 'pcms-embed-card--' + provider);
-    el.classList.remove('pcms-embed-loading');
-
-    // Our player side (registry peer). pause() points to the adapter once
-    // mounted; before that it's a no-op.
-    let adapter = null;
-    const self = { pause() { if (adapter && adapter.pause) { try { adapter.pause(); } catch (e) {} } } };
-
-    // Teardown hook: called by the MutationObserver when this card leaves the
-    // DOM (HTMX swap) → clean up the adapter (timers/iframes) + release the registry,
-    // so no poll timers or players keep leaking.
-    el._pcmsDestroy = function () {
-      try { if (adapter && adapter.destroy) adapter.destroy(); } catch (e) {}
-      registry().release(self);
-    };
-
-    let playing = false;
-    let mounted = false;
-    let dur = 0;
-
-    // --- Mount the UI (differs per provider type) ---
-    const isVideo = provider === 'youtube';
-    const custom = provider === 'youtube' || provider === 'soundcloud'; // eigen controls
-    // A bare playlist has no video id, and interpolating null gave
-    // i.ytimg.com/vi/null/hqdefault.jpg — a 404 painted as the poster.
-    const posterId = provider === 'youtube' ? ytId(ref, url) : null;
-    const poster = posterId ? `https://i.ytimg.com/vi/${posterId}/hqdefault.jpg` : '';
-
-    el.innerHTML = ''
-      + (isVideo
-          ? `<div class="pcms-embed-stage"><div class="pcms-embed-mount"></div>`
-            + `<button type="button" class="pcms-embed-poster"${poster ? ` style="background-image:url('${poster}')"` : ''} aria-label="Afspelen">`
-            + `<span class="pcms-embed-bigplay">${ICON.play}</span></button></div>`
-          : `<div class="pcms-embed-audio">`
-            + `<button type="button" class="pcms-embed-art" aria-label="Afspelen"><span class="pcms-embed-bigplay">${ICON.play}</span></button>`
-            + `<div class="pcms-embed-info"><div class="pcms-embed-title">${LABEL[provider] || provider}</div>`
-            + `<div class="pcms-embed-sub"></div></div>`
-            + `<div class="pcms-embed-mount"></div></div>`)
-      + (custom
-          ? `<div class="pcms-embed-bar">`
-            + `<button type="button" class="pcms-embed-pp" aria-label="Afspelen/pauzeren">${ICON.play}</button>`
-            + `<span class="pcms-embed-cur mono">0:00</span>`
-            + `<div class="pcms-embed-seek" role="slider" aria-label="Voortgang" tabindex="0"><div class="pcms-embed-seek-fill"></div></div>`
-            + `<span class="pcms-embed-dur mono">0:00</span>`
-            + `<button type="button" class="pcms-embed-mute" aria-label="Dempen">${ICON.volume}</button>`
-            + `<input type="range" class="pcms-embed-vol" min="0" max="100" value="100" aria-label="Volume">`
-            + `<a class="pcms-embed-badge" href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">${LABEL[provider] || provider}</a>`
-            + `</div>`
-          : `<div class="pcms-embed-frame-badge"><a href="${escAttr(safeHref(url))}" target="_blank" rel="noopener">via ${LABEL[provider] || provider}</a></div>`);
-
-    const mountEl = el.querySelector('.pcms-embed-mount');
-    const ppBtn = el.querySelector('.pcms-embed-pp');
-    const curEl = el.querySelector('.pcms-embed-cur');
-    const durEl = el.querySelector('.pcms-embed-dur');
-    const seekEl = el.querySelector('.pcms-embed-seek');
-    const seekFill = el.querySelector('.pcms-embed-seek-fill');
-    const posterBtn = el.querySelector('.pcms-embed-poster, .pcms-embed-art');
-    const subEl = el.querySelector('.pcms-embed-sub');
-    const volEl = el.querySelector('.pcms-embed-vol');
-    const muteBtn = el.querySelector('.pcms-embed-mute');
-    let vol = 100;       // current volume 0-100 (applied to the adapter)
-    let preMuteVol = 100;
-
-    function setPlayingUI(on) {
-      playing = on;
-      el.classList.toggle('is-playing', on);
-      if (ppBtn) ppBtn.innerHTML = on ? ICON.pause : ICON.play;
-    }
-    function setProgress(cur, total) {
-      if (total > 0) { dur = total; if (durEl) durEl.textContent = fmt(total); }
-      if (curEl) curEl.textContent = fmt(cur);
-      if (seekFill && dur > 0) seekFill.style.width = Math.max(0, Math.min(100, (cur / dur) * 100)) + '%';
-    }
-
-    const hooks = {
-      onReady(meta) {
-        el.classList.add('is-ready');
-        el.classList.remove('pcms-embed-busy');
-        if (meta && meta.title && subEl) subEl.textContent = meta.title;
-        if (meta && meta.artwork) {
-          const art = el.querySelector('.pcms-embed-art');
-          if (art) { art.style.backgroundImage = `url('${meta.artwork}')`; art.classList.add('has-art'); }
-        }
-        if (meta && meta.duration) setProgress(0, meta.duration);
-      },
-      onPlay() { setPlayingUI(true); registry().setActive(self); },
-      onPause() { setPlayingUI(false); },
-      onEnded() { setPlayingUI(false); setProgress(0, dur); registry().release(self); },
-      onProgress(cur, total) { setProgress(cur, total); },
-    };
-
-    // API blocked/unreachable → plain platform iframe (graceful degradation).
-    // Mutual exclusion for this fallback runs via the blur heuristic
-    // (audio-player.js), because the card never gets .is-mounted.
-    function renderFallback() {
-      const fb = fallbackIframe(provider, ref, url);
-      if (!fb.src) { el.classList.remove('pcms-embed-busy'); el.classList.add('pcms-embed-error'); return; }
-      const iframe = document.createElement('iframe');
-      iframe.src = fb.src;
-      iframe.loading = 'lazy';
-      iframe.title = LABEL[provider] || provider;
-      iframe.setAttribute('allow', 'autoplay; encrypted-media; clipboard-write; picture-in-picture; fullscreen');
-      if (fb.fs) iframe.allowFullscreen = true;
-      iframe.style.cssText = fb.ratio
-        ? 'width:100%;aspect-ratio:16/9;border:0;display:block;'
-        : 'width:100%;height:' + (fb.h || '152px') + ';border:0;display:block;';
-      el.classList.remove('is-mounted', 'pcms-embed-busy');
-      el.classList.add('pcms-embed-fallback');
-      el.innerHTML = '';
-      el.appendChild(iframe);
-
-      // Mutual exclusion also for the fallback iframe. A cross-origin iframe
-      // cannot be paused via an API, so 'pause' = reload WITHOUT
-      // autoplay (= stops the audio, player stays visible/restartable).
-      // We register it as active: now (= user starts the embed) the
-      // site player/other embeds pause; and when the site player starts later,
-      // the registry pauses this fallback.
-      self.pause = function () {
-        try {
-          const noAuto = fb.src
-            .replace(/([?&])(?:autoplay=1|auto_play=true)(&|$)/gi, '$1')
-            .replace(/[?&]$/, '');
-          if (iframe.src === noAuto) {
-            // src ongewijzigd (bv. Spotify zonder autoplay) → forceer een reload
-            iframe.src = 'about:blank';
-            setTimeout(() => { try { iframe.src = noAuto; } catch (e) {} }, 30);
-          } else {
-            iframe.src = noAuto;
-          }
-        } catch (e) {}
-      };
-      registry().setActive(self);
-    }
-
-    // First interaction → mount the adapter + play. The button toggles after that.
-    async function ensureMountedAndPlay() {
-      if (mounted) { if (adapter) adapter.play(); return; }
-      mounted = true;
-      // Spotify: their player cannot be skinned (controls-only) and the
-      // iFrame API bundle often fails to initialise in practice (CDN-gating/503)
-      // → don't wait for the API, show the plain Spotify iframe immediately.
-      if (provider === 'spotify') { renderFallback(); return; }
-      el.classList.add('pcms-embed-busy');
-      try {
-        adapter = await MOUNTERS[provider](mountEl, { ref, url }, hooks);
-        if (el._pcmsApplyVol) el._pcmsApplyVol();  // onthouden volume toepassen
-        // is-mounted: CSS hides the poster (video) or Spotify facade and
-        // shows the real player. For SoundCloud our card+bar stays visible and
-        // the (functional) iframe remains hidden off-screen.
-        el.classList.add('is-mounted');
-        adapter.play();
-      } catch (err) {
-        console.warn('[pcms-embed] API unavailable, falling back to plain iframe', provider, err);
-        renderFallback();
-      }
-    }
-
-    if (posterBtn) posterBtn.addEventListener('click', ensureMountedAndPlay);
-    if (ppBtn) ppBtn.addEventListener('click', () => {
-      if (!mounted) return ensureMountedAndPlay();
-      if (playing) { adapter && adapter.pause(); } else { adapter && adapter.play(); }
-    });
-    if (seekEl) seekEl.addEventListener('click', (e) => {
-      if (!adapter || !adapter.seek || !dur) return;
-      const rect = seekEl.getBoundingClientRect();
-      const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
-      adapter.seek(ratio * dur);
-    });
-    // Volume: slider sets vol (0-100) and applies it to the adapter (YT/SC support
-    // setVolume). The value is remembered and reapplied after mounting.
-    function applyVol() {
-      if (adapter && adapter.setVolume) { try { adapter.setVolume(vol); } catch (e) {} }
-      if (muteBtn) muteBtn.innerHTML = vol === 0 ? ICON.muted : ICON.volume;
-      if (volEl && String(volEl.value) !== String(vol)) volEl.value = vol;
-      el.classList.toggle('is-muted', vol === 0);
-    }
-    if (volEl) volEl.addEventListener('input', () => { vol = parseInt(volEl.value, 10) || 0; if (vol > 0) preMuteVol = vol; applyVol(); });
-    if (muteBtn) muteBtn.addEventListener('click', () => {
-      if (vol > 0) { preMuteVol = vol; vol = 0; } else { vol = preMuteVol || 100; }
-      applyVol();
-    });
-    el._pcmsApplyVol = applyVol;  // called by ensureMountedAndPlay after mount
-  }
-
-  function escAttr(s) {
-    return String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
-  }
-
-  // ============================================================
-  // 4. Adapters — normalise the 3 very different APIs to one common shape.
-  //    Each mounter returns { play, pause, seek } and calls hooks with
-  //    seconds (units are normalised here).
-  // ============================================================
-  const MOUNTERS = {
-    // ---- YouTube: IFrame Player API, eigen controls (controls:0) ----
-    async youtube(mountEl, { ref, url }, hooks) {
-      const YT = await ytApi();
-      const id = ytId(ref, url);
-      const list = ytList(ref, url);
-      return new Promise((resolve, reject) => {
-        let pollTimer = null;
-        let endTimer = null;       // see onStateChange: a list "ends" between items
-        const player = new YT.Player(mountEl, {
-          // With a video AND a list, `list` makes the player continue into the
-          // playlist after this song. Without a video it is the playlist
-          // itself, and then listType says so -- videoId must stay absent, or
-          // the API loads that one video and forgets the list.
-          ...(id ? { videoId: id } : {}),
-          host: 'https://www.youtube-nocookie.com',
-          playerVars: {
-            controls: 0, modestbranding: 1, rel: 0, playsinline: 1, fs: 0,
-            disablekb: 1, iv_load_policy: 3, origin: window.location.origin,
-            ...(list ? (id ? { list } : { list, listType: 'playlist' }) : {}),
-          },
-          events: {
-            onReady() {
-              let d = 0; try { d = player.getDuration() || 0; } catch (e) {}
-              hooks.onReady({ duration: d });
-              resolve({
-                play() { try { player.playVideo(); } catch (e) {} },
-                pause() { try { player.pauseVideo(); } catch (e) {} },
-                seek(sec) { try { player.seekTo(sec, true); } catch (e) {} },
-                setVolume(pct) { try { if (pct <= 0) player.mute(); else { player.unMute(); player.setVolume(pct); } } catch (e) {} },
-                destroy() {
-                  if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
-                  if (endTimer) { clearTimeout(endTimer); endTimer = null; }
-                  try { player.destroy(); } catch (e) {}
-                },
-              });
-            },
-            onStateChange(e) {
-              // -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering, 5 cued
-              if (e.data === 1) {
-                // A next playlist item started, so the pending "it is over" was
-                // false alarm.
-                if (endTimer) { clearTimeout(endTimer); endTimer = null; }
-                hooks.onPlay();
-                if (!pollTimer) pollTimer = setInterval(() => {
-                  try { hooks.onProgress(player.getCurrentTime() || 0, player.getDuration() || 0); } catch (e) {}
-                }, 250);
-              } else if (e.data === 2) {
-                hooks.onPause();
-                if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
-              } else if (e.data === 0) {
-                if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
-                // INSIDE a playlist, YouTube reports "ended" between every two
-                // songs as well. Firing onEnded there hands the queue on after
-                // song one and cuts the album off. So on a list we wait, and
-                // only a silence that is not interrupted by the next song
-                // counts as the end. Same rule as the hub (2.5s).
-                if (list) {
-                  if (!endTimer) endTimer = setTimeout(() => { endTimer = null; hooks.onEnded(); }, 2500);
-                } else {
-                  hooks.onEnded();
-                }
-              }
-            },
-            onError() { reject(new Error('YT error')); },
-          },
-        });
-      });
-    },
-
-    // ---- SoundCloud: Widget API, visual=false, custom controls ----
-    async soundcloud(mountEl, { ref, url }, hooks) {
-      const SC = await scApi();
-      // Build the iframe ourselves (bare bar) and then attach SC.Widget to it.
-      const iframe = document.createElement('iframe');
-      iframe.allow = 'autoplay';
-      iframe.title = 'SoundCloud';
-      const params = new URLSearchParams({
-        url: ref || url, visual: 'false', auto_play: 'false', hide_related: 'true',
-        show_comments: 'false', show_user: 'false', show_teaser: 'false',
-        sharing: 'false', buy: 'false', download: 'false', show_artwork: 'true',
-        single_active: 'true', color: 'ff5500',
-      });
-      iframe.src = 'https://w.soundcloud.com/player/?' + params.toString();
-      mountEl.appendChild(iframe);
-      const widget = SC.Widget(iframe);
-      const E = SC.Widget.Events;
-      return new Promise((resolve, reject) => {
-        let resolved = false;
-        widget.bind(E.READY, () => {
-          widget.getCurrentSound((sound) => {
-            const meta = sound ? {
-              title: sound.title || '',
-              artwork: (sound.artwork_url || (sound.user && sound.user.avatar_url) || '').replace('-large', '-t300x300'),
-            } : {};
-            widget.getDuration((ms) => { meta.duration = (ms || 0) / 1000; hooks.onReady(meta); });
-          });
-          resolved = true;
-          resolve({
-            play() { widget.play(); },
-            pause() { widget.pause(); },
-            seek(sec) { widget.seekTo(sec * 1000); },
-            setVolume(pct) { try { widget.setVolume(pct); } catch (e) {} },
-            destroy() {
-              try { ['READY', 'PLAY', 'PAUSE', 'FINISH', 'PLAY_PROGRESS', 'ERROR'].forEach((k) => E[k] && widget.unbind(E[k])); } catch (e) {}
-              try { iframe.remove(); } catch (e) {}
-            },
-          });
-        });
-        widget.bind(E.PLAY, () => hooks.onPlay());
-        widget.bind(E.PAUSE, () => hooks.onPause());
-        widget.bind(E.FINISH, () => hooks.onEnded());
-        widget.bind(E.PLAY_PROGRESS, (d) => {
-          hooks.onProgress((d.currentPosition || 0) / 1000, 0);
-        });
-        widget.bind(E.ERROR, () => { if (!resolved) reject(new Error('SC error')); });
-        setTimeout(() => { if (!resolved) reject(new Error('SC timeout')); }, 12000);
-      });
-    },
-
-    // ---- Spotify: iFrame API — controls + our frame; no custom skin ----
-    async spotify(mountEl, { ref, url }, hooks) {
-      const IFrameAPI = await spotifyApi();
-      const uri = ref || url;
-      return new Promise((resolve, reject) => {
-        let lastPaused = true, resolved = false;
-        IFrameAPI.createController(mountEl, { uri, width: '100%', height: 152 }, (controller) => {
-          controller.addListener('ready', () => {
-            hooks.onReady({});
-            resolved = true;
-            resolve({
-              play() { try { controller.resume(); } catch (e) { try { controller.play(); } catch (e2) {} } },
-              pause() { try { controller.pause(); } catch (e) {} },
-              seek(sec) { try { controller.seek(sec); } catch (e) {} },
-              destroy() { try { controller.destroy(); } catch (e) {} },
-            });
-          });
-          controller.addListener('playback_update', (e) => {
-            const d = e && e.data ? e.data : {};
-            hooks.onProgress((d.position || 0) / 1000, (d.duration || 0) / 1000);
-            // No reliable 'ended' event from Spotify; we treat every
-            // isPaused transition as play/pause. End = just a pause (the bar
-            // stays at the end position rather than misleadingly jumping to 0).
-            if (d.isPaused === false && lastPaused) { lastPaused = false; hooks.onPlay(); }
-            else if (d.isPaused === true && !lastPaused) { lastPaused = true; hooks.onPause(); }
-          });
-        });
-        setTimeout(() => { if (!resolved) reject(new Error('Spotify timeout')); }, 12000);
-      });
-    },
-  };
-
-  // ============================================================
-  // 5. Scan + HTMX/DOM-mutation-aware (the site partially navigates via HTMX swaps)
-  // ============================================================
-  function scan(root) {
-    (root || document).querySelectorAll('.folio-embed[data-embed-provider]').forEach((el) => {
-      if (el.dataset.embedInit) return;
-      el.dataset.embedInit = '1';
-      try { buildCard(el); } catch (e) { console.error('[pcms-embed] buildCard failed', e); }
-    });
-  }
-
-  if (document.readyState === 'loading') {
-    document.addEventListener('DOMContentLoaded', () => scan(document));
-  } else {
-    scan(document);
-  }
-  // HTMX replaces #pcms-main on internal navigation → rescan.
-  document.body.addEventListener('htmx:afterSwap', (e) => scan(e.target || document));
-  document.body.addEventListener('htmx:load', (e) => scan(e.target || document));
-
-  // Teardown on DOM removal (HTMX replaces #pcms-main innerHTML, or an SPA-like
-  // swap). Without this, YouTube poll timers + adapters/iframes linger when
-  // navigating away while an embed is playing → CPU/memory leak that accumulates
-  // per navigation. We call el._pcmsDestroy() for every card that truly leaves the document.
-  const teardownObserver = new MutationObserver((muts) => {
-    for (const m of muts) {
-      m.removedNodes.forEach((node) => {
-        if (node.nodeType !== 1) return;
-        const cards = [];
-        if (node.matches && node.matches('.folio-embed[data-embed-init]')) cards.push(node);
-        if (node.querySelectorAll) node.querySelectorAll('.folio-embed[data-embed-init]').forEach((c) => cards.push(c));
-        cards.forEach((c) => {
-          if (typeof c._pcmsDestroy === 'function' && !document.contains(c)) {
-            try { c._pcmsDestroy(); } catch (e) {}
-          }
-        });
-      });
-    }
-  });
-  try { teardownObserver.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
-
-  window.pcmsEmbedPlayer = { scan, registry };
-})();
Index: src/assets/js/guardian.js
===================================================================
--- src/assets/js/guardian.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,1214 +1,0 @@
-/* Guardian PWA client (FEP-633c): renders the dashboard, adopts wards, and
-   manages the guardian push channel. No framework, no inline scripts (CSP).
-   All user-facing text comes from state.strings (server i18n). */
-(function () {
-  'use strict';
-  // A crash here used to fail silently (buttons just do nothing). Surface it on
-  // the page AND the console so the cause is visible instead of "everything hangs".
-  function fatal(msg) {
-    try {
-      var b = document.getElementById('g-fatal') || document.createElement('div');
-      b.id = 'g-fatal'; b.className = 'g-msg err';
-      b.style.cssText = 'display:block;margin:12px 0;padding:10px 14px';
-      b.textContent = 'Guardian: ' + msg;
-      var root = document.querySelector('main') || document.body;
-      if (!b.parentNode && root) root.insertBefore(b, root.firstChild);
-    } catch (e) { /* last resort */ }
-    try { console.error('[guardian]', msg); } catch (e) { /* no console */ }
-  }
-  try {
-  var S = JSON.parse(document.getElementById('guardian-state').textContent || '{}');
-  var T = S.strings || {};
-
-  function el(tag, cls, text) {
-    var n = document.createElement(tag);
-    if (cls) n.className = cls;
-    if (text != null) n.textContent = text;
-    return n;
-  }
-  function handleOf(uri, cached) {
-    if (cached && cached.charAt(0) === '@') return cached;   // trust only real @handles
-    try { var u = new URL(uri); return '@' + u.pathname.split('/').filter(Boolean).pop() + '@' + u.host; }
-    catch (e) { return uri; }
-  }
-  // The server hands over a timestamp already formatted in the site's timezone
-  // (Beheer -> Instellingen), the same clock de Krant and Berichten show. The
-  // slice is only a fallback for a row that predates that field: it shows raw
-  // UTC, which is what made a 20:20 call for help read 18:20.
-  function when(item, raw) {
-    if (item && item.when_text) return item.when_text;
-    return String(raw || '').slice(0, 16).replace('T', ' ');
-  }
-  function show(id, on) { document.getElementById(id).hidden = !on; }
-
-  // ── 1. Help requests ───────────────────────────────────────────────────
-  // A call for help is not an alarm: it may well be settled quietly between a
-  // guardian and the child. So the card carries no siren, it just has to be
-  // impossible to miss. It shows up twice on purpose (Robins keuze): the recent
-  // ones across all children at the top, the full history of one child in that
-  // child's panel.
-  var HELP_TOP = 5;
-
-  /**
-   * Wacht deze hulpvraag nog op iemand?
-   *
-   * EEN plek, met opzet. Dit werd eerst op twee plekken los beslist -- de lijst
-   * en de teller op de wardregel -- en toen de lijst gefilterd werd bleef die
-   * teller een afgehandelde vraag meetellen. Een derde plek komt er vast, en dan
-   * hoort hij hier langs te komen.
-   *
-   * Bij TWIJFEL open: alles wat geen expliciete afsluiting draagt telt als
-   * wachtend. De omgekeerde fout -- iets als afgehandeld tonen dat het niet is --
-   * is hier de gevaarlijke.
-   */
-  function helpOpen(h) { return !h || !h.state || (h.state.open && !h.state.formerWard); }
-
-  function helpCard(h) {
-    var card = el('div', 'g-card help');
-    var row = el('div', 'row');
-    var who = el('span', 'who grow');
-    // name_html carries the custom emojis (FEP-9098) of the display name, the
-    // same way de Krant renders a byline. Falls back to the plain name.
-    if (h.name_html) who.innerHTML = h.name_html;
-    else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle);
-    row.appendChild(who);
-    row.appendChild(el('span', 'when', when(h, h.published || h.created_at)));
-    card.appendChild(row);
-    var body = el('div', 'body g-note');
-    // body_html is the shared note-body partial, rendered server-side: the
-    // content with its emojis, the quote / link-preview card and the media.
-    // Falls back to the bare content for rows stored before that existed.
-    body.innerHTML = h.body_html || h.content || '';   // sanitized server-side on ingest
-    card.appendChild(body);
-    if (h.note_url) {
-      var a = el('a', 'g-link', T.open || 'open');
-      a.href = h.note_url; a.target = '_blank'; a.rel = 'noopener';
-      card.appendChild(a);
-    }
-    card.appendChild(helpState(h));
-    return card;
-  }
-
-  /** Hoe lang geleden, grof. Een oppik vervalt niet maar hoort wel te verouderen. */
-  function ago(ms) {
-    var u = Math.floor(ms / 3600000);
-    if (u < 1) return T.help_just_now || 'just now';
-    if (u < 24) return (T.help_hours || '{n}h ago').replace('{n}', u);
-    return (T.help_days || '{n}d ago').replace('{n}', Math.floor(u / 24));
-  }
-
-  /**
-   * De gedeelde staat van een hulpvraag (shaer-lgo): wie er al op af is, of het
-   * is afgesloten, en de twee knoppen.
-   *
-   * De faalstand hier is NIET veilig: 'iedereen denkt dat het geregeld is' is
-   * gevaarlijker dan geen markering. Dus bij twijfel toont dit OPEN, en een oude
-   * oppik verkleurt in plaats van te verdwijnen -- anders ziet een hulpvraag er
-   * onaangeroerd uit terwijl er iemand mee bezig is.
-   */
-  function helpState(h) {
-    var st = h.state || { open: true, pickedUpBy: [], handled: null, oldestPickupAt: null };
-    var box = el('div', 'g-help-state');
-
-    // Niet meer jouw ward: geen knoppen, want de route zou ze weigeren. En geen
-    // "afgehandeld", want dat zou een claim zijn over een kind waar je niets meer
-    // over te zeggen hebt.
-    if (st.formerWard) {
-      box.appendChild(el('div', 'g-help-former', T.help_former_ward || ''));
-      return box;
-    }
-
-    if (st.handled) {
-      var wie = st.handled.handle || handleOf(st.handled.uri);
-      box.appendChild(el('div', 'g-help-done', (T.help_handled_by || 'Handled by {who}').replace('{who}', wie)));
-      // Geen terugdraaiknop: leeft de vraag nog, dan wordt hij opnieuw gesteld.
-      box.appendChild(el('p', 'small g-empty', T.help_handled_note || ''));
-      return box;
-    }
-
-    (st.pickedUpBy || []).forEach(function (p) {
-      var line = (T.help_picked_by || '{who} is looking into this').replace('{who}', p.handle || handleOf(p.uri));
-      box.appendChild(el('div', 'g-help-pick', line));
-    });
-    // Oud, maar niet weg. Dit is het verschil tussen 'er is iemand mee bezig' en
-    // 'er was ooit iemand mee bezig'.
-    // De leeftijd wordt HIER uitgerekend, niet op de server: een verschil met
-    // `now` in het antwoord maakt dat antwoord elke milliseconde anders, en dan
-    // kan de ETag nooit gelijk zijn.
-    var oud = st.oldestPickupAt ? (Date.now() - Date.parse(st.oldestPickupAt)) : null;
-    if (oud != null && !isNaN(oud) && oud > 3600000) {
-      box.appendChild(el('div', 'g-help-age small', ago(oud)));
-    }
-
-    var row = el('div', 'row');
-    var mij = (st.pickedUpBy || []).some(function (p) { return p.uri === S.me; });
-    if (!mij) {
-      var pick = el('button', 'small', T.help_pick || 'I am on it');
-      pick.addEventListener('click', function () { markHelp(h, 'pickup', pick); });
-      row.appendChild(pick);
-    }
-    var done = el('button', 'quiet small', T.help_close || 'Mark handled');
-    // Een stevige bevestiging, en nooit een window.confirm: dat verstopt een
-    // uitleg achter een OK die mensen wegklikken. Zelfde lijn als het loslaten
-    // van een ward.
-    done.addEventListener('click', function () {
-      done.hidden = true;
-      var ask = el('div', 'g-confirm');
-      ask.appendChild(el('p', 'small', T.help_close_ask || ''));
-      var ja = el('button', 'small', T.help_close_yes || 'Yes, handled');
-      ja.addEventListener('click', function () { markHelp(h, 'handled', ja); });
-      var nee = el('button', 'quiet small', T.release_no || 'No');
-      nee.addEventListener('click', function () { ask.remove(); done.hidden = false; });
-      ask.appendChild(ja); ask.appendChild(nee);
-      box.appendChild(ask);
-    });
-    row.appendChild(done);
-    box.appendChild(row);
-    return box;
-  }
-
-  function markHelp(h, kind, btn) {
-    if (btn) btn.disabled = true;
-    fetch('/guardian/api/help/' + kind, {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ note: h.object_uri, ward: h.actor_uri, site: S.site }),
-    }).then(refresh).catch(function () { if (btn) btn.disabled = false; });
-  }
-
-  function renderHelp() {
-    var list = document.getElementById('help-list');
-    list.textContent = '';
-    var alle = S.help || [];
-    // Afgehandeld hoort niet meer in de lijst die om aandacht vraagt, en niet
-    // meer in de teller: anders blijft het cijfer alarm slaan voor iets dat af
-    // is, en neemt een gesloten vraag de ruimte in van een open vraag.
-    var open = alle.filter(helpOpen);
-    var klaar = alle.filter(function (h) { return !helpOpen(h); });
-
-    open.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); });
-    if (open.length > HELP_TOP) {
-      list.appendChild(el('p', 'g-sec-sub', '+ ' + (open.length - HELP_TOP) + ' — ' + (T.panel_help || '')));
-    }
-
-    // WEG is niet hetzelfde als AF. Een afgehandelde hulpvraag blijft bestaan --
-    // er wordt in dit systeem niets herschreven -- maar hij hoort achter een
-    // klik, niet voor je neus. De volledige geschiedenis per kind staat sowieso
-    // in het paneel van dat kind.
-    if (klaar.length) {
-      var doos = el('div', 'g-help-archive');
-      doos.hidden = true;
-      klaar.forEach(function (h) { doos.appendChild(helpCard(h)); });
-      var knop = el('button', 'quiet small', (T.help_archive || '{n} handled').replace('{n}', klaar.length));
-      knop.addEventListener('click', function () {
-        doos.hidden = !doos.hidden;
-        knop.textContent = doos.hidden
-          ? (T.help_archive || '{n} handled').replace('{n}', klaar.length)
-          : (T.help_archive_hide || 'hide');
-      });
-      list.appendChild(knop);
-      list.appendChild(doos);
-    }
-
-    var badge = document.getElementById('help-count');
-    badge.textContent = open.length; badge.hidden = open.length === 0;
-    // "Geen hulpverzoeken. Mooi zo." mag ook staan als er wel een archief is:
-    // er wacht dan immers niets.
-    show('help-empty', open.length === 0);
-  }
-
-  // ── 3. Offers I am a party to (sent, or a co-guardianship to co-approve) ─
-  function answer(offerId, decision, btn) {
-    if (btn) btn.disabled = true;
-    fetch('/guardian/offer', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ offer: offerId, answer: decision, site: S.site }),
-    }).then(refresh);
-  }
-  function offerCard(o) {
-    var card = el('div', 'g-card');
-    var row = el('div', 'row');
-    var subject = o['shaer:iAmCandidate']
-      ? handleOf(o['shaer:ward'], o['shaer:wardHandle'])            // my sent offer: about the ward
-      : handleOf(o['shaer:candidate'], o['shaer:candidateHandle']); // co-guard: who wants in
-    row.appendChild(el('span', 'who grow', subject));
-    if (o['shaer:iAmCandidate']) {
-      // My own offer, waiting for the others to accept.
-      row.appendChild(el('span', 'tag wait', T.pending));
-      var rt = el('button', 'quiet small', T.retract);
-      rt.addEventListener('click', function () { answer(o.id, 'reject', rt); });
-      row.appendChild(rt);
-    } else if (o['shaer:needsMyAccept']) {
-      // A co-guardianship offer for a ward I already guard: my call.
-      row.appendChild(el('span', 'tag co', T.coguard));
-      var ac = el('button', 'small', T.accept);
-      ac.addEventListener('click', function () { answer(o.id, 'accept', ac); });
-      var rj = el('button', 'quiet small', T.reject);
-      rj.addEventListener('click', function () { answer(o.id, 'reject', rj); });
-      row.appendChild(ac); row.appendChild(rj);
-    } else {
-      row.appendChild(el('span', 'tag wait', T.awaiting_others));
-    }
-    card.appendChild(row);
-    return card;
-  }
-  /** A running lapse (FEP-633c 3.6.3): the available co-guardians deciding
-   *  to release a dormant one. Votes ride the same Accept/Reject wire as the
-   *  offers; buttons appear only for set members (the ward watches, it does
-   *  not vote). */
-  function lapseCard(l) {
-    var card = el('div', 'g-card lapse');
-    card.appendChild(el('div', 'who', (T.lapse_line || '{who} has stopped answering as a guardian of {ward}.')
-      .replace('{who}', handleOf(l.object.object)).replace('{ward}', handleOf(l.object['shaer:ward']))));
-    card.appendChild(el('div', 'g-avlabel', (T.lapse_tally || '{n} of {need} agreed; closes {date}.')
-      .replace('{n}', l['shaer:accepts']).replace('{need}', l['shaer:threshold'])
-      .replace('{date}', new Date(l['shaer:closesAt']).toLocaleDateString())));
-    var row = el('div', 'row');
-    var inSet = (l['shaer:set'] || []).indexOf(S.me) >= 0;
-    if (inSet && !l['shaer:myVote']) {
-      var yes = el('button', 'small', T.lapse_agree || 'Agree');
-      yes.addEventListener('click', function () { answer(l.id, 'accept', yes); });
-      var no = el('button', 'quiet small', T.lapse_disagree || 'Disagree');
-      no.addEventListener('click', function () { answer(l.id, 'reject', no); });
-      row.appendChild(yes); row.appendChild(no);
-    } else if (inSet) {
-      row.appendChild(el('span', 'g-avlabel', T.voted || 'You voted'));
-    }
-    card.appendChild(row);
-    card.appendChild(el('p', 'g-empty small', T.lapse_note || ''));
-    return card;
-  }
-
-  /** A gated-setting proposal (FEP-633c 5.6) a fellow guardian opened on a
-   *  ward we share, forwarded here by the ward's server. Answering is the
-   *  whole point: without a second voice the threshold is never met and the
-   *  proposal quietly expires. */
-  function gatedCard(g) {
-    var card = el('div', 'g-card gated');
-    var line = g.value ? (T.gated_line_on || '') : (T.gated_line_off || '');
-    card.appendChild(el('div', 'who', line
-      .replace('{who}', handleOf(g.proposer || ''))
-      .replace('{ward}', handleOf(g.ward))));
-    // Wat het betekent als dit doorgaat, VOOR de knoppen (shaer-nf9). Alleen bij
-    // openzetten -- dichtzetten laat niets nieuws door.
-    //
-    // En eerlijk over wat wij niet zien: de stemmen worden geteld op de server
-    // van het kind, niet hier. Er zijn geen lokale wards, dus gatedProgress komt
-    // voor deze kaart altijd op null uit. Een verzonnen "1 van 2" zou hier het
-    // gevaarlijkste getal op het scherm zijn -- het leest als "er kan nog iemand
-    // na mij". Wat we wel weten en wat de waarschuwing draagt: jouw ja kan de
-    // doorslag geven, want de telling settelt op het moment dat de drempel
-    // gehaald is.
-    if (g.consequence) {
-      var warn = el('div', 'g-warn');
-      warn.appendChild(el('p', 'small', warnText(g.consequence)));
-      // Wat we WEL weten sinds shaer-8vt: de server van het kind heeft
-      // meegestuurd of jouw antwoord het afmaakt. Zo niet -- een oudere server
-      // -- dan blijft de algemene regel staan, want bij twijfel waarschuwen.
-      warn.appendChild(el('p', g.decisive === false ? 'small g-dim' : 'small g-decisive',
-        g.decisive === false ? (T.warn_not_last || '') : (T.warn_decides || T.warn_tally_elsewhere || '')));
-      card.appendChild(warn);
-    }
-
-    var row = el('div', 'row');
-    var yes = el('button', 'small', T.gated_agree || 'Agree');
-    var no = el('button', 'quiet small', T.gated_disagree || 'Disagree');
-    function answerGated(decision, btn) {
-      btn.disabled = true;
-      fetch('/guardian/api/gated/' + encodeURIComponent(g.id), {
-        method: 'POST', headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ answer: decision, site: S.site }),
-      }).then(refresh).catch(function () { btn.disabled = false; });
-    }
-    yes.addEventListener('click', function () { answerGated('accept', yes); });
-    no.addEventListener('click', function () { answerGated('reject', no); });
-    row.appendChild(yes); row.appendChild(no);
-    card.appendChild(row);
-    return card;
-  }
-
-  function renderPending() {
-    var list = document.getElementById('pending-list');
-    list.textContent = '';
-    var offers = S.offers || [];
-    var gated = S.gatedReviews || [];
-    // The offers state carries the adoption offers; the lapse proposals ride
-    // separately so a lapse never renders as an adoption.
-    var lapses = (S.lapses || []).filter(function (l) { return l['shaer:outcome'] === 'open'; });
-    offers.forEach(function (o) {
-      if (o.object && o.object.type === 'shaer:Lapse') return;   // rendered below
-      list.appendChild(offerCard(o));
-    });
-    lapses.forEach(function (l) { list.appendChild(lapseCard(l)); });
-    gated.forEach(function (g) { list.appendChild(gatedCard(g)); });
-    show('pending-section', offers.length > 0 || lapses.length > 0 || gated.length > 0);
-  }
-
-  // ── 4. Accepted wards: one panel per child ─────────────────────────────
-  // A guardian thinks per child, not per function, so everything about one
-  // child sits behind that child's row: the gated settings, the follow requests
-  // waiting on them, their calls for help, their recent posts. The row itself
-  // carries counts, so nothing that needs an answer hides inside a closed
-  // panel.
-  var openPanels = {};   // ward uri -> open, so a refresh does not close it
-  var openGates = {};    // idem voor de poortenlijst: uitklappen mag niet elke 45s dichtvallen
-  // The follow requests and the wards' posts arrive from their own endpoints
-  // and are grouped into the panels by ward, so they are cached here rather
-  // than rendered into a section of their own.
-  var FEED = [], FOLLOWS = [], EVENTS = [];
-
-  function sectionInto(panel, title, items, empty, build) {
-    var h = el('div', 'g-panel-sec');
-    h.appendChild(el('h3', null, title));
-    if (!items.length) h.appendChild(el('p', 'g-empty small', empty));
-    else items.forEach(function (it) { h.appendChild(build(it)); });
-    panel.appendChild(h);
-    return h;
-  }
-
-  function gateButton(w, feature, current, proposeLabel, onLabel, offLabel, consequence) {
-    var known = current === true || current === false;
-    var btn = el('button', 'quiet small', (known ? (current ? onLabel : offLabel) : proposeLabel) || feature);
-    var allow = known ? !current : true;
-
-    // ALLEEN BIJ OPENZETTEN waarschuwen (shaer-nf9). Dichtzetten laat niets
-    // nieuws door, en een waarschuwing die overal staat wordt nergens gelezen.
-    // Geen window.confirm: dat verstopt de uitleg achter een OK die mensen
-    // wegklikken. Zelfde vorm als het loslaten van een ward.
-    btn.addEventListener('click', function () {
-      if (!allow) return doPropose();
-      btn.hidden = true;
-      var ask = el('div', 'g-confirm');
-      ask.appendChild(el('p', 'small', warnText(consequence)));
-      var ja = el('button', 'small', T.warn_go || 'Yes, propose this');
-      ja.addEventListener('click', function () { ask.remove(); btn.hidden = false; doPropose(); });
-      var nee = el('button', 'quiet small', T.warn_back || 'No, go back');
-      nee.addEventListener('click', function () { ask.remove(); btn.hidden = false; });
-      ask.appendChild(ja); ask.appendChild(nee);
-      btn.parentNode.appendChild(ask);
-    });
-
-    function doPropose() {
-      btn.disabled = true;
-      fetch('/guardian/wards/embeds', {
-        method: 'POST', headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ uri: w.other_uri, feature: feature, allow: allow }),
-      }).then(function (r) { return r.json(); })
-        .then(function (j) {
-          if (j && j.state === 'open') {
-            btn.textContent = (T.embeds_waiting || 'waiting for the other guardians');
-            btn.disabled = true;
-            return;
-          }
-          refresh();
-        })
-        .catch(function () { btn.disabled = false; });
-    }
-    return btn;
-  }
-
-  /** De juiste waarschuwing bij een gevolg. Onbekend krijgt de zwaarste: een
-   *  gate die wij niet kennen kunnen we ook niet geruststellend beschrijven. */
-  function warnText(consequence) {
-    if (consequence === 'irreversible') return T.warn_irreversible || '';
-    if (consequence === 'unknown') return T.warn_unknown || '';
-    return T.warn_reversible || '';
-  }
-
-  function embedsButton(w) {
-    // Gated feature: external (non-fediverse) embeds. Off by default for a
-    // ward; only a guardian can open it, and the gate is enforced server-side
-    // when the feed is built, so this button is the only thing that moves it.
-    // Shown for EVERY ward, including one on another server. There the value is
-    // unknown (it lives on the ward's server), but proposing is exactly as
-    // possible: the proposal travels, the ward's server tallies the guardians
-    // and enforces. A guardian next door must not have more say than one far
-    // away.
-    var known = w.embeds === true || w.embeds === false;
-    var emb = el('button', 'quiet small',
-      (known ? (w.embeds ? T.embeds_on : T.embeds_off) : T.embeds_propose) || 'Link previews');
-    emb.addEventListener('click', function () {
-      emb.disabled = true;
-      fetch('/guardian/wards/embeds', {
-        method: 'POST', headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ uri: w.other_uri, allow: known ? !w.embeds : true }),
-      }).then(function (r) { return r.json(); })
-        .then(function (j) {
-          // Not settled yet: the other guardians still have to answer.
-          if (j && j.state === 'open') {
-            emb.textContent = (T.embeds_waiting || 'waiting for the other guardians');
-            emb.disabled = true;
-            return;
-          }
-          refresh();
-        })
-        .catch(function () { emb.disabled = false; });
-    });
-    return emb;
-  }
-
-  /**
-   * The second step of releasing a ward: what it does, then yes or no.
-   *
-   * The warning is assembled from what the server found, not from a fixed
-   * sentence, because releasing means two different things (FEP-633c): stepping
-   * down while other guardians remain (§3.3), or being the last one, which is
-   * emancipation and explicitly not one guardian's call (§3.4). And as long as
-   * the Undo does not federate, the ward's server keeps listing you either way
-   * — a guardian has to know that before pressing, not after.
-   */
-  function releaseStep(w, check, host, relBtn) {
-    var uri = w.other_uri;
-    var who = handleOf(uri, w.other_handle);
-    var box = el('div', 'g-warn');
-    box.appendChild(el('strong', null, (T.release_title || 'Release {who}?').replace('{who}', who)));
-    box.appendChild(el('p', null, T.release_effect || ''));
-    if (check.last === true) box.appendChild(el('p', 'grave', T.release_last || ''));
-    else if (check.last === false) box.appendChild(el('p', null, T.release_step_down || ''));
-    else box.appendChild(el('p', 'grave', T.release_unknown || ''));
-    box.appendChild(el('p', null, T.release_local || ''));
-
-    var row = el('div', 'row');
-    var no = el('button', 'small', T.release_no || 'No');
-    no.addEventListener('click', function () {
-      host.removeChild(box);
-      relBtn.hidden = false; relBtn.disabled = false;
-    });
-    // No first: the way out should be the easy one to hit.
-    row.appendChild(no);
-    // Being the last guardian is not a warning but a wall: the server refuses
-    // it (§3.4), so offering a yes here would only produce an error. The text
-    // above already says what has to happen instead.
-    if (check.last !== true) {
-      var yes = el('button', 'danger small', T.release_yes || 'Yes');
-      yes.addEventListener('click', function () {
-        yes.disabled = true;
-        remove(uri, yes, function (err) {
-          // The guardian set can change between the check and the click.
-          yes.disabled = false;
-          box.appendChild(el('p', 'grave', err === 'would_emancipate' ? (T.release_last || '') : (T.failed || '')));
-          if (err === 'would_emancipate') yes.remove();
-        });
-      });
-      row.appendChild(yes);
-    }
-    box.appendChild(row);
-    return box;
-  }
-
-  /** The availability dot (FEP-633c 3.6): buddy-list language on the
-   *  responsibility axis. Green available, yellow declared away with an end,
-   *  grey observed dormant (one answer restores). */
-  function availLabel(g) {
-    if (g.availability === 'away') {
-      var date = g.awayUntil ? new Date(g.awayUntil).toLocaleDateString() : '?';
-      return (T.avail_away || 'Unavailable till {date}').replace('{date}', date);
-    }
-    if (g.availability === 'dormant') return T.avail_dormant || 'Offline';
-    return T.avail_available || 'Available';
-  }
-  function availRow(g, wardUri) {
-    var row = el('div', 'row g-guard');
-    var dot = el('span', 'g-avdot ' + (g.availability === 'away' ? 'is-away' : g.availability === 'dormant' ? 'is-dormant' : 'is-active'));
-    row.appendChild(dot);
-    row.appendChild(el('span', 'who grow', handleOf(g.uri, g.handle)));
-    row.appendChild(el('span', 'g-avlabel', availLabel(g)));
-    // A dormant fellow guardian without a running lapse: the deliberate,
-    // rare next step (3.6.3). Never shown for anyone still answering.
-    if (g.availability === 'dormant' && !g.lapse && g.uri !== S.me) {
-      var btn = el('button', 'quiet small', T.lapse_propose || 'Propose release');
-      btn.addEventListener('click', function () {
-        btn.disabled = true;
-        fetch('/guardian/api/lapse', {
-          method: 'POST', headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ ward: wardUri, target: g.uri, site: S.site }),
-        }).then(refresh).catch(function () { btn.disabled = false; });
-      });
-      row.appendChild(btn);
-    }
-    return row;
-  }
-
-  /** Het woord dat we voor een gate gebruiken; onbekend valt terug op de naam. */
-  function gateLabel(feature) {
-    return T['gate_' + feature.replace('shaer:', '')] || feature.replace('shaer:', '');
-  }
-
-  /**
-   * Een rij in het gate-paneel. Toont WAT er geldt, van welk SOORT het is, welke
-   * drempel er hoort en wat er loopt -- en pas daarna een knop, als er iets te
-   * verzetten valt.
-   *
-   * Het soort staat erbij omdat de gates niet hetzelfde werken: een stand is aan
-   * of uit, volgverzoeken zijn een stroom beslissingen, en een overdracht is
-   * onomkeerbaar zodra het kind hem gebruikt. Vier rijen met dezelfde schakelaar
-   * zouden dat verschil wegpoetsen.
-   */
-  /**
-   * Een gate als cockpit-schakelaar (Barts richting, 8-8): een knop zoals de
-   * airco-toets in een dashboard -- lampje links, de naam erop, en een klein
-   * statusveld dat de stand toont. De KNOP is de toggle: aanraken stelt de
-   * andere stand voor, met de nf9-uitleg ervoor. Het lampje en het veld dragen
-   * dezelfde stand in twee vormen, want een lampje alleen is voor kleurenblind
-   * en onwennig volk geen antwoord.
-   *
-   * Drie standen, niet twee. "Uit" en "voor zover wij weten uit" zijn niet
-   * hetzelfde: dat tweede betekent dat niemand er ooit over besloot, en dat
-   * hoort niet te lezen als een genomen besluit. Wat er nog niet bestaat is
-   * een lege fitting, geen dichte deur. En een lopend voorstel knippert --
-   * wachten is de enige stand die vanzelf verandert, dus de enige die om je
-   * blik mag vragen (en bij prefers-reduced-motion staat hij stil).
-   */
-  function gateRow(w, g) {
-    var stand = g.value === null || g.value === undefined
-      ? (T.gate_unknown || 'unknown')
-      : (g.value ? (T.prop_on || 'on') : (T.prop_off || 'off'));
-    if (!g.decided && g.value === false) stand = T.gate_default_off || stand;
-    // Een VASTE poort is geen stand die nog kan draaien: hij staat zoals de spec
-    // hem zet. "aan" of "uit" leest als iets waar nog over te praten valt, en
-    // dat is hier niet zo -- §5.3 laat er geen ruimte voor.
-    if (g.fixed) stand = T.gate_always || 'always';
-    if (!g.available) stand = T.gate_unavailable || 'not available yet';
-
-    var lamp = !g.available ? 'planned'
-      : (g.waiting || (g.proposal && g.proposal.status === 'open')) ? 'waiting'
-      : (g.value === true ? 'on' : (g.decided ? 'off' : 'undecided'));
-
-    var wrap = el('div', 'g-sw-wrap');
-    var btn = el('button', 'g-sw');
-    btn.type = 'button';
-    if (g.value === true || g.value === false) btn.setAttribute('aria-pressed', String(g.value));
-    var head = el('span', 'g-sw-head');
-    var lampEl = el('span', 'g-sw-lamp g-lamp-' + lamp);
-    lampEl.setAttribute('aria-hidden', 'true');
-    head.appendChild(lampEl);
-    head.appendChild(el('span', 'g-sw-name', gateLabel(g.feature)));
-    btn.appendChild(head);
-    // Het statusveld: de stand als tekst, in een verzonken venstertje zoals de
-    // temperatuur op het aircopaneel. Bij een lopend voorstel telt het veld wat
-    // er wacht -- dat is op dat moment de waarheid over deze poort.
-    var veld = (g.waiting ? (T.gate_waiting || '{n} waiting').replace('{n}', g.waiting) : stand);
-    btn.appendChild(el('span', 'g-sw-state', veld));
-
-    var meta = el('span', 'g-sw-meta small');
-    meta.appendChild(el('span', 'g-gate-kind', T['gate_kind_' + g.kind] || g.kind));
-    if (!g.available) meta.appendChild(el('span', 'g-dim', T.gate_planned_note || ''));
-    if (g.threshold && g.available) {
-      meta.appendChild(el('span', null, (T.gate_threshold || '{need} of {of} guardians')
-        .replace('{need}', g.threshold.need).replace('{of}', g.threshold.of)));
-    } else if (g.available) {
-      // Geen drempel verzinnen die we niet kennen.
-      meta.appendChild(el('span', 'g-dim', T.gate_threshold_unknown || ''));
-    }
-    if (!g.reversible) meta.appendChild(el('span', 'g-gate-warn', T.gate_irreversible || ''));
-    btn.appendChild(meta);
-    wrap.appendChild(btn);
-
-    // Het kind vroeg hier ZELF om (shaer-8ru). Eigen regel onder de schakelaar,
-    // niet in het statusveld: dit is het enige op deze rij dat van het kind komt
-    // en het hoort niet weg te vallen tussen de drempels en de soorten. Ook
-    // bewust niet als "n waiting" -- drie onbekenden die je kind willen volgen is
-    // iets heel anders dan je kind dat een keer vraagt of muziek aan mag.
-    if (g.requested) wrap.appendChild(el('p', 'small g-gate-asked', T.gate_asked || ''));
-
-    if (g.proposal) {
-      wrap.appendChild(el('p', 'small g-prop g-prop-' + g.proposal.status,
-        (T.prop_line || 'Proposal {what} {value}: {status}')
-          .replace('{what}', gateLabel(g.feature))
-          .replace('{value}', g.proposal.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
-          .replace('{status}', T['prop_st_' + g.proposal.status] || g.proposal.status)));
-    }
-    if (g.blockedBy) {
-      wrap.appendChild(el('p', 'small g-empty', (T.gate_blocked || 'needs {what} first')
-        .replace('{what}', gateLabel(g.blockedBy))));
-    }
-
-    if (!g.adjustable) {
-      // Niet verstelbaar is niet hetzelfde als kapot: het lampje en het veld
-      // blijven de stand tonen, alleen de toets drukt niet in.
-      btn.disabled = true;
-      return wrap;
-    }
-
-    // De toets stelt de ANDERE stand voor; de richting volgt de stand die we
-    // uit de besluiten kennen, dus ook voor een ward elders kun je dichtzetten
-    // voorstellen. Bij openzetten eerst de nf9-uitleg (uitleg staat er, dan pas
-    // de knop); dichtzetten laat niets nieuws door en vraagt niet.
-    var allow = (g.value === true || g.value === false) ? !g.value : true;
-    var consequence = g.reversible === false ? 'irreversible' : 'reversible';
-    btn.addEventListener('click', function () {
-      if (!allow) return doPropose();
-      if (wrap.querySelector('.g-confirm')) return;   // uitleg staat er al
-      var ask = el('div', 'g-confirm');
-      ask.appendChild(el('p', 'small', warnText(consequence)));
-      var ja = el('button', 'small', T.warn_go || 'Yes, propose this');
-      ja.addEventListener('click', function () { ask.remove(); doPropose(); });
-      var nee = el('button', 'quiet small', T.warn_back || 'No, go back');
-      nee.addEventListener('click', function () { ask.remove(); });
-      ask.appendChild(ja); ask.appendChild(nee);
-      wrap.appendChild(ask);
-    });
-
-    function doPropose() {
-      btn.disabled = true;
-      fetch('/guardian/wards/embeds', {
-        method: 'POST', headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ uri: w.other_uri, feature: g.feature, allow: allow }),
-      }).then(function (r) { return r.json(); })
-        .then(function (j) {
-          if (j && j.state === 'open') {
-            wrap.appendChild(el('p', 'small g-prop g-prop-open',
-              (T.prop_line || 'Proposal {what} {value}: {status}')
-                .replace('{what}', gateLabel(g.feature))
-                .replace('{value}', allow ? (T.prop_on || 'on') : (T.prop_off || 'off'))
-                .replace('{status}', T.prop_st_open || 'open')));
-          }
-          refresh();
-        })
-        .catch(function () { btn.disabled = false; });
-    }
-    return wrap;
-  }
-
-  function wardPanel(w) {
-    var uri = w.other_uri;
-    var panel = el('div', 'g-panel');
-    panel.hidden = !openPanels[uri];
-
-    var set = el('div', 'g-panel-sec');
-    set.appendChild(el('h3', null, T.settings_title || 'Settings'));
-    // Een rij per gate, uit de catalogus van de server (shaer-ahy.1). Losse
-    // knoppen lieten een guardian zelf uitzoeken wat er allemaal geldt, en wat
-    // niet verstelbaar is stond nergens -- terwijl dat de helft van het antwoord
-    // is op "wat mag dit kind".
-    //
-    // INGEKLAPT met een samenvatting erboven. Met twaalf poorten duwt een open
-    // lijst alles wat eronder staat -- guardians, volgverzoeken, hulpvragen --
-    // van het scherm. Wat je meestal wilt weten is "staat er iets open en wacht
-    // er iets op mij", en dat past op een regel.
-    if ((w.gates || []).length) {
-      var lijst = el('div', 'g-gates');
-      lijst.hidden = !openGates[uri];
-      w.gates.forEach(function (g) { lijst.appendChild(gateRow(w, g)); });
-
-      var aan = w.gates.filter(function (g) { return g.available && g.value === true; }).length;
-      var wacht = w.gates.reduce(function (n, g) { return n + (g.waiting || 0) + (g.proposal && g.proposal.status === 'open' ? 1 : 0); }, 0);
-      var kop = el('button', 'quiet small g-gates-toggle');
-      var zetKop = function () {
-        kop.textContent = (lijst.hidden ? (T.gates_show || 'Show gates') : (T.gates_hide || 'Hide gates'))
-          + ' - ' + (T.gates_summary || '{n} gates - {on} on, {wait} waiting')
-            .replace('{n}', w.gates.length).replace('{on}', aan).replace('{wait}', wacht);
-      };
-      zetKop();
-      kop.addEventListener('click', function () {
-        lijst.hidden = !lijst.hidden;
-        openGates[uri] = !lijst.hidden;   // blijft open over een verversing heen
-        zetKop();
-      });
-      set.appendChild(kop);
-      set.appendChild(lijst);
-    }
-    // Terugval voor een server die de catalogus nog niet stuurt: dan de twee
-    // knoppen zoals ze waren, zodat een oudere Klonkt niet met een leeg vak zit.
-    if (!(w.gates || []).length) {
-      var setRow = el('div', 'row');
-      setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off));
-      if (w.embeds !== false) {
-        setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off));
-      }
-      set.appendChild(setRow);
-      (w.proposals || []).forEach(function (p) {
-        var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews');
-        set.appendChild(el('p', 'small g-prop g-prop-' + p.status, (T.prop_line || 'Proposal {what} {value}: {status}')
-          .replace('{what}', what).replace('{value}', p.value ? (T.prop_on || 'on') : (T.prop_off || 'off'))
-          .replace('{status}', T['prop_st_' + p.status] || p.status)));
-      });
-    }
-    panel.appendChild(set);
-
-    // The fellow guardians of this child, with availability (3.6). For a
-    // ward on another server the states live there, and saying so honestly
-    // beats guessing.
-    var gsec = el('div', 'g-panel-sec');
-    gsec.appendChild(el('h3', null, T.panel_guards || 'Guardians of this child'));
-    if (w.guardians && w.guardians.length) {
-      w.guardians.forEach(function (g) { gsec.appendChild(availRow(g, uri)); });
-    } else {
-      // A ward on another server: WHO guards it is public on its actor
-      // (shaer:guardians, 2.1), so list the seats; availability is the ward
-      // server's private ledger (3.6.1) and is not shown, only named.
-      var placeholder = el('p', 'g-empty small', '…');
-      gsec.appendChild(placeholder);
-      fetch('/guardian/wards/guardians?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
-        .then(function (r) { return r.json(); })
-        .then(function (j) {
-          if (!j || !j.guardians || !j.guardians.length) {
-            placeholder.textContent = T.panel_guards_remote || '';
-            return;
-          }
-          placeholder.remove();
-          j.guardians.forEach(function (g) {
-            var row = el('div', 'row g-guard');
-            row.appendChild(el('span', 'who grow', handleOf(g.uri, g.handle)));
-            gsec.appendChild(row);
-          });
-          gsec.appendChild(el('p', 'g-empty small', T.panel_guards_far || ''));
-        })
-        .catch(function () { placeholder.textContent = T.panel_guards_remote || ''; });
-    }
-    panel.appendChild(gsec);
-
-    sectionInto(panel, T.panel_follow || 'Follow requests',
-      FOLLOWS.filter(function (f) { return f.wardUri === uri; }),
-      T.panel_follow_empty || '', followCard);
-
-    // Alleen wat nog WACHT. De afgehandelde vragen staan onderaan het paneel als
-    // eigen sectie: dooreen gezet lees je niet meer of er iets van je gevraagd
-    // wordt, en dat is de enige vraag die deze sectie hoort te beantwoorden.
-    sectionInto(panel, T.panel_help || 'Calls for help',
-      (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }),
-      T.panel_help_empty || '', helpCard);
-
-    sectionInto(panel, T.panel_posts || 'Recent posts',
-      FEED.filter(function (p) { return p.authorUri === uri; }),
-      T.panel_posts_empty || '', feedCard);
-
-    // De geschiedenis: apart, en helemaal onderaan. Wat af is hoort niet tussen
-    // wat nog wacht -- daar wordt "moet ik iets doen" onleesbaar van. Weg gaat
-    // hij niet: er wordt in dit systeem niets herschreven, er wordt toegevoegd.
-    // Geen lege sectie als er nog niets is afgehandeld; dat is geen informatie.
-    var gedaan = (S.help || []).filter(function (h) { return h.actor_uri === uri && !helpOpen(h); });
-    if (gedaan.length) {
-      sectionInto(panel, (T.panel_history || 'Handled ({n})').replace('{n}', gedaan.length),
-        gedaan, '', helpCard).classList.add('g-panel-history');
-    }
-
-    var act = el('div', 'g-panel-sec');
-    act.appendChild(el('h3', null, T.panel_actions || 'Actions'));
-    var actRow = el('div', 'row');
-    var wave = el('button', 'small', T.wave || '👋 Wave');
-    wave.addEventListener('click', function () { sendWave(uri, wave); });
-    actRow.appendChild(wave);
-    var rel = el('button', 'quiet small', T.release);
-    // Letting a child go is a decision, not a click. It opens a step that first
-    // asks the server what releasing this particular ward actually does, then
-    // says it plainly and asks yes or no. Never window.confirm: that hides a
-    // long explanation behind an OK button people press to make it go away.
-    rel.addEventListener('click', function () {
-      rel.disabled = true;
-      // site matters: with several of your own sites the server would otherwise
-      // check this ward against the wrong one and answer "not my ward".
-      fetch('/guardian/wards/release-check?site=' + encodeURIComponent(S.site) + '&uri=' + encodeURIComponent(uri))
-        .then(function (r) { return r.json(); })
-        .then(function (c) {
-          rel.hidden = true;
-          act.appendChild(releaseStep(w, c || {}, act, rel));
-        })
-        .catch(function () { rel.disabled = false; });
-    });
-    actRow.appendChild(rel);
-    act.appendChild(actRow);
-    // Bovenaan, niet onderaan: zwaaien en loslaten zijn de dingen die je DOET.
-    // De rest van het paneel is lezen -- instellingen, wie er nog meer op let,
-    // wat er binnenkwam. Wie het paneel opent om iets te doen hoorde eerst langs
-    // vijf secties te scrollen.
-    panel.insertBefore(act, panel.firstChild);
-    return panel;
-  }
-
-  function renderWards() {
-    var list = document.getElementById('wards-list');
-    list.textContent = '';
-    var wards = S.wards || [];
-    wards.forEach(function (w) {
-      var uri = w.other_uri;
-      var card = el('div', 'g-card ward');
-      var row = el('div', 'row');
-      row.appendChild(el('span', 'who grow', handleOf(uri, w.other_handle)));
-      // Counts on the row: whatever is waiting must be visible with the panel shut.
-      // Alleen wat nog WACHT, zoals het commentaar hierboven al zei: een
-      // afgehandelde vraag hoorde de boei niet te laten staan.
-      var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri && helpOpen(h); }).length;
-      var nFollow = FOLLOWS.filter(function (f) { return f.wardUri === uri; }).length;
-      if (nHelp) row.appendChild(el('span', 'tag help', '🛟 ' + nHelp));
-      if (nFollow) row.appendChild(el('span', 'tag co', nFollow + ' ' + (nFollow === 1 ? (T.badge_follow_one || '') : (T.badge_follow || ''))));
-      row.appendChild(el('span', 'tag ok', T.active));
-      var toggle = el('button', 'quiet small', openPanels[uri] ? T.panel_close : T.panel_open);
-      row.appendChild(toggle);
-      card.appendChild(row);
-      var panel = wardPanel(w);
-      card.appendChild(panel);
-      toggle.addEventListener('click', function () {
-        openPanels[uri] = !openPanels[uri];
-        panel.hidden = !openPanels[uri];
-        toggle.textContent = openPanels[uri] ? T.panel_close : T.panel_open;
-      });
-      list.appendChild(card);
-    });
-    show('wards-empty', wards.length === 0);
-    // Step away (3.6.1) only means something with wards to tell.
-    show('away-section', wards.length > 0);
-  }
-
-  // ── 4b. Step away (FEP-633c 3.6.1) ─────────────────────────────────────
-  function declareAway(days, btn) {
-    btn.disabled = true;
-    fetch('/guardian/api/away', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ days: days, site: S.site }),
-    }).then(function (r) { return r.json(); })
-      .then(function (j) {
-        btn.disabled = false;
-        var msg = document.getElementById('away-msg');
-        msg.hidden = false;
-        if (j && j.ok) {
-          msg.className = 'g-msg';
-          msg.textContent = (T.away_done || 'Your wards know you are unavailable until {date}.')
-            .replace('{date}', new Date(j.until).toLocaleDateString());
-        } else {
-          msg.className = 'g-msg err';
-          msg.textContent = (j && j.error) || (T.failed || 'failed');
-        }
-      })
-      .catch(function () { btn.disabled = false; });
-  }
-  var awayWeek = document.getElementById('away-week');
-  var awayMonth = document.getElementById('away-month');
-  if (awayWeek) awayWeek.addEventListener('click', function () { declareAway(7, awayWeek); });
-  if (awayMonth) awayMonth.addEventListener('click', function () { declareAway(30, awayMonth); });
-
-  function sendWave(uri, btn) {
-    btn.disabled = true;
-    fetch('/guardian/api/wave', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ ward: uri, site: S.site }),
-    }).then(function (r) { return r.json(); })
-      .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
-      .catch(function () { btn.disabled = false; });
-  }
-
-  function remove(uri, btn, onError) {
-    btn.disabled = true;
-    fetch('/guardian/wards/remove', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ uri: uri, site: S.site }),
-    }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
-      .then(function (res) {
-        // The server can refuse: emptying shaer:guardians is emancipation and
-        // not one guardian's call (§3.4). Say so instead of silently redrawing.
-        if (!res.ok) { if (onError) onError(res.j && res.j.error); return; }
-        refresh();
-      })
-      .catch(function () { if (onError) onError('network'); else btn.disabled = false; });
-  }
-
-
-  // De scrollpositie overleeft een verversing (Barts melding, 9-8). renderAll
-  // bouwt lijsten opnieuw op; staat de pagina daarbij even korter, dan springt
-  // de browser omhoog en ben je kwijt waar je was. Meten en terugzetten is
-  // genoeg -- en het is het verschil tussen een scherm dat bijwerkt en een
-  // scherm dat onder je handen wegschuift.
-  function metBehoudVanScroll(fn) {
-    var y = window.scrollY || document.documentElement.scrollTop || 0;
-    fn();
-    if (y) window.scrollTo(0, y);
-  }
-
-  function renderAll() { renderHelp(); renderPending(); renderWards(); }
-
-  // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
-  // Lives inside each child's panel now, so the fetches only fill a cache and
-  // ask the ward list to redraw. A guardian watches, it does not publish.
-  function feedCard(p) {
-    var card = el('div', 'g-card feed');
-    var head = el('div', 'row');
-    head.appendChild(el('span', 'who grow', p.author));
-    if (p.published) head.appendChild(el('span', 'g-when', when(p, p.published)));
-    card.appendChild(head);
-    var body = el('div', 'feed-body');
-    // body_html is de gedeelde note-body-partial, serverside gerenderd: opmaak,
-    // media, quote-kaart en embed, precies als in de Krant en in Berichten.
-    // Valt terug op de kale content voor een client uit de cache.
-    var html = p.body_html || p.content || '';
-    if (p.cw) {
-      // De content warning blijft van de PWA zelf: note-body versluiert alleen
-      // bij nsfw, en een ward-post met alleen een cw hoort hier dicht te staan.
-      var d = document.createElement('details');
-      var sum = document.createElement('summary'); sum.textContent = p.cw; d.appendChild(sum);
-      var inner = el('div'); inner.innerHTML = html; d.appendChild(inner);
-      body.appendChild(d);
-    } else {
-      body.innerHTML = html;   // server-sanitized HTML (same as Berichten)
-    }
-    card.appendChild(body);
-    return card;
-  }
-
-  function loadFeed() {
-    return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
-      .then(function (r) { return r.json(); })
-      .then(function (f) { if (f && !f.error) { FEED = f.items || []; renderWards(); } })
-      .catch(function () { /* panels just show "nothing yet" */ });
-  }
-
-  // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
-  /**
-   * Twee richtingen, twee eindpunten. Ze deelden er een, en dat kon niet goed
-   * gaan: /api/follow zoekt in ap_pending_follows en vindt een uitgaand verzoek
-   * daar nooit. De knop deed dus niets en zei niets -- erger dan geen knop.
-   */
-  function answerFollow(id, decision, btn, uitgaand) {
-    if (btn) btn.disabled = true;
-    var pad = uitgaand ? '/guardian/api/outgoing-follow/' : '/guardian/api/follow/';
-    fetch(pad + encodeURIComponent(id), {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ decision: decision, site: S.site }),
-    }).then(loadFollowReqs);
-  }
-  function followCard(f) {
-    var uitgaand = f.direction === 'outgoing';
-    var card = el('div', 'g-card');
-    var row = el('div', 'row');
-    // Binnen het paneel van het kind is de ward een gegeven, dus alleen de
-    // ANDER wordt genoemd -- en wie dat is hangt van de richting af. Zonder dat
-    // onderscheid stond bij een uitgaand verzoek de ward zelf op de kaart
-    // ("je kind wil je kind volgen") en verdween degene om wie het ging.
-    row.appendChild(el('span', 'who grow',
-      uitgaand ? ((T.follow_out_line || 'wants to follow {who}').replace('{who}', f.target || '?'))
-               : f.follower));
-    var ok = el('button', 'small', T.accept || 'Accept');
-    ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok, uitgaand); });
-    var no = el('button', 'quiet small', T.reject || 'Deny');
-    no.addEventListener('click', function () { answerFollow(f.id, 'reject', no, uitgaand); });
-    row.appendChild(ok); row.appendChild(no);
-    card.appendChild(row);
-    return card;
-  }
-  /**
-   * Het logboek. Apart geladen en apart getoond: dit is geschiedenis, geen
-   * wachtrij. Faalt hij, dan blijft de sectie gewoon weg -- een guardian mist
-   * er niets door dat om hem vraagt.
-   */
-  function loadEvents() {
-    return fetch('/guardian/api/events?site=' + encodeURIComponent(S.site))
-      .then(function (r) { return r.json(); })
-      .then(function (d) { if (d && !d.error) { EVENTS = d.items || []; renderLog(); } })
-      .catch(function () { /* geen logboek is geen storing */ });
-  }
-
-  /** Een regel per gebeurtenis: wat er gebeurde, om wie, en waarom. */
-  function eventLine(e) {
-    var wie = e.candidate || e.guardian || e.ward || e.follower || e.target || '';
-    var stuk = (T['ev_' + e.kind] || e.kind);
-    if (wie) stuk += ' - ' + handleOf(wie, wie);
-    // De REDEN is waarvoor dit logboek bestaat: zonder haar merkte een ward een
-    // weigering alleen doordat er iets uit een lijst verdween.
-    if (e.reason) stuk += ' (' + (T['evr_' + e.reason] || e.reason) + ')';
-    var row = el('div', 'g-card');
-    row.appendChild(el('span', 'who grow', stuk));
-    row.appendChild(el('span', 'when', when(e, e.created)));
-    return row;
-  }
-
-  function renderLog() {
-    var host = document.getElementById('g-log');
-    if (!host) return;
-    host.textContent = '';
-    if (!EVENTS.length) return;
-    var lijst = el('div', 'g-gates');
-    lijst.hidden = !S._logOpen;
-    EVENTS.forEach(function (e) { lijst.appendChild(eventLine(e)); });
-    var kop = el('button', 'quiet small g-gates-toggle');
-    var zet = function () {
-      kop.textContent = (lijst.hidden ? (T.log_show || 'Show history') : (T.log_hide || 'Hide history'))
-        + ' (' + EVENTS.length + ')';
-    };
-    zet();
-    kop.addEventListener('click', function () {
-      lijst.hidden = !lijst.hidden; S._logOpen = !lijst.hidden; zet();
-    });
-    host.appendChild(kop); host.appendChild(lijst);
-  }
-
-  function loadFollowReqs() {
-    return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
-      .then(function (r) { return r.json(); })
-      .then(function (f) { if (f && !f.error) { FOLLOWS = f.items || []; renderWards(); } })
-      .catch(function () { /* panels just show "none waiting" */ });
-  }
-
-  function refresh() {
-    return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
-      .then(function (r) { return r.json(); })
-      .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; metBehoudVanScroll(renderAll); } })
-      .then(loadFeed).then(loadFollowReqs);
-  }
-
-  // ── De lange poll (Barts opdracht, 9-8) ──────────────────────────────
-  //
-  // Het paneel tikte elke 45 seconden, ongeacht of er iets gebeurd was. Nu hangt
-  // er een verzoek open tot er iets is dat je moet verwerken -- een aanbod, een
-  // volgverzoek, een gate-voorstel, een hulpvraag. Dat is niet alleen zuiniger,
-  // het is ook SNELLER: een hulproep stond eerst tot drie kwartier van een
-  // minuut op het scherm te wachten.
-  //
-  // 304 = stilte, en dan meteen opnieuw wachten. Alleen bij 200 wordt er iets
-  // opnieuw getekend.
-  var pollBezig = false;
-  var laatsteState = null;   // de vorige body, om onnodig hertekenen te herkennen
-
-  function longPoll() {
-    if (pollBezig || document.hidden) return;
-    pollBezig = true;
-    var t0 = Date.now();
-    fetch('/guardian/api/state?wait=25&site=' + encodeURIComponent(S.site))
-      .then(function (r) {
-        pollBezig = false;
-        if (r.status === 304) return null;          // stilte
-        return r.text().then(function (tekst) {
-          // ZELFDE INHOUD, NIET HERTEKENEN. De 304 zou dit al moeten afvangen,
-          // maar een enkel veld dat per verzoek verandert (er stond een levende
-          // klok in) maakt elke ETag anders, en dan tekent het paneel zichzelf
-          // stuk terwijl je scrollt. Dit is de tweede grendel, aan de kant die
-          // de gebruiker merkt.
-          if (tekst === laatsteState) return null;
-          laatsteState = tekst;
-          var s; try { s = JSON.parse(tekst); } catch (e) { return null; }
-          if (s && !s.error) { S = s; T = s.strings || T; metBehoudVanScroll(renderAll); }
-          return loadFeed().then(loadFollowReqs);
-        });
-      })
-      .then(function () {
-        // EEN LANGE POLL DIE METEEN TERUGKEERT MAG GEEN LUS WORDEN. Wachtte de
-        // server niet (een fout, een tussenliggende proxy, iets wat we nog niet
-        // weten), dan houden we zelf de rem erop. Anders vervangt een defect de
-        // ene tik van 45 seconden door duizend per minuut.
-        var snel = Date.now() - t0 < 2000;
-        setTimeout(longPoll, snel ? 3000 : 200);
-      })
-      .catch(function () {
-        // Netwerk weg of server herstart: niet meteen opnieuw beuken.
-        pollBezig = false;
-        setTimeout(longPoll, 5000);
-      });
-  }
-
-  // ── 2. Adopt ───────────────────────────────────────────────────────────
-  var form = document.getElementById('adopt-form');
-  var input = document.getElementById('adopt-handle');
-  var adoptBtn = document.getElementById('adopt-btn');
-  var msg = document.getElementById('adopt-msg');
-  function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
-
-  form.addEventListener('submit', function (ev) {
-    ev.preventDefault();
-    var handle = input.value.trim();
-    if (!handle) return;
-    adoptBtn.disabled = true;
-    setMsg(T.sending || '…', false);
-    fetch('/guardian/adopt', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ handle: handle, site: S.site }),
-    }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
-      .then(function (res) {
-        adoptBtn.disabled = false;
-        if (res.ok) {
-          input.value = '';
-          // Always refresh: the offer is recorded even if delivery is still
-          // in flight. Show it under "Verzonden aanvragen".
-          setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
-          refresh();
-        } else {
-          setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
-        }
-      })
-      .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
-  });
-
-  // ── Site picker ────────────────────────────────────────────────────────
-  var picker = document.getElementById('site-picker');
-  if (picker) picker.addEventListener('change', function () {
-    location.href = '/guardian?site=' + encodeURIComponent(picker.value);
-  });
-
-  // ── 5. Push ────────────────────────────────────────────────────────────
-  var toggle = document.getElementById('push-toggle');
-  var pmsg = document.getElementById('push-msg');
-  function pushState() {
-    if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
-    navigator.serviceWorker.register('/sw.js').catch(function () {});
-    navigator.serviceWorker.ready
-      .then(function (reg) { return reg.pushManager.getSubscription(); })
-      .then(function (sub) {
-        toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
-        toggle.dataset.subscribed = sub ? '1' : '';
-        toggle.classList.toggle('is-on', !!sub);
-      });
-  }
-  function urlB64(base64) {
-    var pad = '='.repeat((4 - (base64.length % 4)) % 4);
-    var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
-    var raw = atob(b); var arr = new Uint8Array(raw.length);
-    for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
-    return arr;
-  }
-  toggle.addEventListener('click', function () {
-    pmsg.hidden = true;
-    navigator.serviceWorker.ready.then(function (reg) {
-      if (toggle.dataset.subscribed) {
-        reg.pushManager.getSubscription().then(function (sub) {
-          if (!sub) return;
-          fetch('/push/unsubscribe', {
-            method: 'POST', headers: { 'Content-Type': 'application/json' },
-            body: JSON.stringify({ endpoint: sub.endpoint }),
-          }).then(function () { return sub.unsubscribe(); }).then(pushState);
-        });
-        return;
-      }
-      fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
-        if (!v.publicKey) throw new Error('no key');
-        return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
-      }).then(function (sub) {
-        return fetch('/push/subscribe', {
-          method: 'POST', headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({
-            subscription: sub.toJSON(),
-            alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
-            uaLabel: 'Guardian PWA',
-          }),
-        });
-      }).then(pushState).catch(function (e) {
-        pmsg.hidden = false; pmsg.className = 'g-msg err';
-        pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
-      });
-    });
-  });
-
-  renderAll(); pushState(); loadFeed(); loadFollowReqs(); loadEvents();
-
-  // De push die de melding brengt is meteen het teken dat de staat veranderd is.
-  // Daarmee hoeft er geen tweede, open verbinding bij: hetzelfde kanaal doet het
-  // werk, en het werkt ook als de app dicht is.
-  if ('serviceWorker' in navigator) {
-    navigator.serviceWorker.addEventListener('message', function (e) {
-      if (e.data && e.data.klonkt === 'push') refresh();
-    });
-  }
-  // Het tikje blijft als vangnet -- niet elke verandering geeft een melding, en
-  // niet iedereen heeft meldingen aanstaan. Maar niet tikken terwijl niemand
-  // kijkt: dat waren verzoeken voor een tabblad op de achtergrond. Bij terugkomen
-  // meteen een keer, want dan is de kans op nieuws het grootst.
-  // De lange poll doet het werk. Het tikje blijft als vangnet, maar veel trager:
-  // valt er een wekker weg (een pad dat niet wekt, een herstart midden in een
-  // verzoek), dan mag het scherm niet voorgoed stilstaan. Vijf minuten in plaats
-  // van vijfenveertig seconden, en met de ETag kost zo'n tik meestal een lege 304.
-  longPoll();
-  setInterval(function () { if (!document.hidden) refresh(); }, 300000);
-  document.addEventListener('visibilitychange', function () {
-    if (document.hidden) return;
-    refresh();
-    longPoll();       // bij terugkomst weer een wachter openzetten
-  });
-  } catch (e) {
-    fatal((e && e.message) || String(e));
-  }
-})();
Index: src/assets/js/install-app.js
===================================================================
--- src/assets/js/install-app.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ src/assets/js/install-app.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
@@ -7,10 +7,10 @@
  *  - On click, opens a modal with platform-specific instructions:
  *      iOS     → "Voeg toe aan beginscherm" via Safari share sheet
- *      Android → PWA install (1-tap if available, else Chrome ⋮-menu instructions)
+ *      Android → choice between PWA install (1-tap if available) and APK download
  *      Desktop → instructions for Chrome/Edge install icon, or native prompt if available
  *  - Listens for `beforeinstallprompt` (Android Chrome / desktop Chromium) and
  *    deferreds it so the modal's "Install as PWA" button can fire it.
  *
- * Markup contract: <button data-pcms-install-app>
+ * Markup contract: <button data-pcms-install-app data-apk-url="/path/to.apk">
  *
  * CSS: .install-app-btn / .install-modal* / .install-choice* — defined in
@@ -30,4 +30,5 @@
   const isIOS     = /iPad|iPhone|iPod/.test(ua) && !window.MSStream;
   const isAndroid = /Android/.test(ua);
+  const apkUrl    = btn.dataset.apkUrl || '';
 
   // Capture Chrome/Edge native install prompt so we can fire it from our modal.
@@ -99,14 +100,71 @@
       : `<p class="install-choice-note">Open deze site in <strong>Chrome</strong> en kies in het <strong>⋮-menu → "App installeren"</strong> (of "Aan beginscherm toevoegen").</p>`;
 
+    const apkBlock = apkUrl ? `
+      <div class="install-choice install-choice-apk">
+        <h4>🤖 Optie B — APK (native Android-app)</h4>
+        <div class="install-pros-cons">
+          <div class="install-pros">
+            <strong>Voordelen</strong>
+            <ul>
+              <li>Echte app in app-lade, naast WhatsApp/Gmail</li>
+              <li>Beste achtergrond-audio ondersteuning</li>
+              <li>Lock-screen controls werken optimaal</li>
+              <li>Blijft geïnstalleerd ongeacht browser-cache</li>
+            </ul>
+          </div>
+          <div class="install-cons">
+            <strong>Nadelen</strong>
+            <ul>
+              <li>Moet eenmalig "onbekende bronnen" toestaan</li>
+              <li>Geen auto-updates — nieuwe versie = APK opnieuw downloaden</li>
+              <li>Browser toont beveiligings-waarschuwing bij install</li>
+              <li>Alleen Android (Android 5+ / API 21+)</li>
+            </ul>
+          </div>
+        </div>
+        <a href="${apkUrl}" class="install-choice-cta" download>
+          <svg width="18" height="18" viewBox="0 0 24 24" style="vertical-align:middle;margin-right:.35rem"><path d="M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z" fill="currentColor"/></svg>
+          Download APK
+        </a>
+        <ol class="install-steps" style="margin-top:.75rem">
+          <li>Tik op de gedownloade APK</li>
+          <li>Sta eenmalig <strong>"installeren van onbekende bronnen"</strong> toe voor je browser</li>
+          <li>Tik <strong>Installeer</strong> in de popup</li>
+          <li>Open de app vanuit je app-lade</li>
+        </ol>
+      </div>
+    ` : '';
+
     showModal(`
       <h3 id="install-title" style="margin:0 0 .5rem">🤖 Installeer op Android</h3>
-      <p style="margin:0 0 1rem;opacity:.8">Voeg deze site als app toe aan je beginscherm:</p>
-      <ul class="install-steps" style="opacity:.85">
-        <li>Instant — geen download of toestemmingen</li>
-        <li>Updatet automatisch bij elke wijziging</li>
-        <li>Minder opslag dan een losse app</li>
-      </ul>
-      ${nativeLine}
-      <p style="margin:1rem 0 0;opacity:.7;font-size:.85rem">⚠️ Werkt het best in Chrome.</p>
+      <p style="margin:0 0 1.25rem;opacity:.8">Twee manieren. Kies wat bij je past:</p>
+
+      <div class="install-choice install-choice-pwa">
+        <h4>📱 Optie A — Home-screen shortcut (PWA)</h4>
+        <div class="install-pros-cons">
+          <div class="install-pros">
+            <strong>Voordelen</strong>
+            <ul>
+              <li>Instant — geen download of toestemmingen</li>
+              <li>Updates automatisch bij elke site-wijziging</li>
+              <li>Geen beveiligings-waarschuwing</li>
+              <li>Minder opslagruimte op je telefoon</li>
+            </ul>
+          </div>
+          <div class="install-cons">
+            <strong>Nadelen</strong>
+            <ul>
+              <li>Vereist Chrome als browser</li>
+              <li>Audio-in-achtergrond iets minder robuust dan native</li>
+              <li>Kan verdwijnen als je browser-data wist</li>
+            </ul>
+          </div>
+        </div>
+        ${nativeLine}
+      </div>
+
+      ${apkBlock}
+
+      <p style="margin:1rem 0 0;opacity:.65;font-size:.82rem">💡 Weet je niet wat te kiezen? Begin met <strong>Optie A (shortcut)</strong>. Te beperkt? Ga dan voor de APK.</p>
     `);
   }
Index: src/assets/js/mod/admin-audio.js
===================================================================
--- src/assets/js/mod/admin-audio.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,454 +1,0 @@
-// Audio in beheer -- verplaatst uit inline script, shaer-bqr.
-//
-// Servergegevens komen uit pageData(); interpolatie kan niet in een statisch
-// bestand. Inline script wordt bovendien geweigerd zodra deze pagina via een
-// link BINNEN de site binnenkomt (shaer-0i6).
-
-import { pageData, makeSweeper } from './lib.js';
-
-// Zie post-edit.js: init() per paginawissel, de veger haalt de window-
-// listeners van de vorige lichting weg (shaer-5s1).
-const doc = makeSweeper();
-let T = {};
-
-export function init() {
-  doc.sweep();
-  T = pageData();
-  run();
-}
-
-function run() {
-
-(function() {
-
-  // ── Live filename display for custom file inputs ──────────────
-  document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
-    const nameEl = input.parentElement.querySelector('.ax-file-name');
-    if (!nameEl) return;
-    input.addEventListener('change', () => {
-      if (input.files && input.files[0]) {
-        nameEl.textContent = input.files[0].name;
-        nameEl.removeAttribute('data-empty');
-      } else {
-        nameEl.textContent = (T.no_file || '');
-        nameEl.setAttribute('data-empty', '');
-      }
-    });
-  });
-
-  // ── Inline track preview (routed through the global mini-player) ──
-  // Each .ax-track-play button is a thin wrapper around the global
-  // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
-  // .is-playing) is synced from the player's own audio element so it
-  // stays accurate even when the user uses the mini-player's controls.
-  (function setupTrackPreview() {
-    const buttons = document.querySelectorAll('.ax-track-play');
-    if (!buttons.length) return;
-
-    function setIcon(btn, playing) {
-      const icon = btn.querySelector('.ax-track-play-icon');
-      if (icon) icon.textContent = playing ? '⏸' : '▶';
-      btn.classList.toggle('is-playing', playing);
-      btn.setAttribute('aria-label', playing ? (T.pause || '') : (T.play || ''));
-    }
-
-    // Resync ALL preview buttons against the current audio element state.
-    // Called on every play/pause/ended event so admins always see the right
-    // icon — including the case where they hit pause on the mini-player
-    // bar instead of the row's button.
-    function resyncAll() {
-      const audio = document.getElementById('audio-element');
-      const player = window.pcmsAudioPlayer;
-      const playing = audio && !audio.paused && !audio.ended;
-      // audio.src is now a blob: URL (Spotify-style playback), so compare
-      // against the player's logical track URL, not the element src.
-      const cur = player && player.currentTrack();
-      const curUrl = cur ? cur.url : '';
-      buttons.forEach(b => {
-        const isThisOne = playing && curUrl === b.dataset.streamUrl;
-        setIcon(b, isThisOne);
-      });
-    }
-
-    buttons.forEach(btn => {
-      btn.addEventListener('click', e => {
-        e.preventDefault();
-        const player = window.pcmsAudioPlayer;
-        if (!player) {
-          console.warn('[admin-audio] miniplayer not available');
-          return;
-        }
-        const url = btn.dataset.streamUrl;
-        if (!url) return;
-
-        // Build track metadata from the row's DOM so the mini-player shows
-        // useful info (title/artist/album/cover) without an extra API call.
-        const row = btn.closest('.ax-track');
-        const titleEl  = row && row.querySelector('[data-cell="title"]');
-        const artistEl = row && row.querySelector('[data-cell="artist"]');
-        const albumEl  = row && row.querySelector('[data-cell="album"]');
-        const coverImg = row && row.querySelector('img[data-cover-thumb]');
-        const track = {
-          url,
-          title:  titleEl  ? titleEl.textContent.trim()  : 'Track',
-          artist: artistEl ? artistEl.textContent.trim() : '',
-          album:  albumEl  ? albumEl.textContent.trim()  : '',
-          cover:  coverImg ? coverImg.src                : '',
-        };
-
-        // If this exact track is already current, toggle pause/play instead
-        // of restarting from zero. Compare logical URLs (audio.src is a blob:).
-        const cur = player.currentTrack();
-        if (cur && cur.url === url) {
-          if (player.isPlaying()) player.pause();
-          else                    player.play();
-          return;
-        }
-
-        player.setQueue([track], 0);
-      });
-    });
-
-    // Hook the global audio element's events to keep the row buttons synced.
-    // We attach lazily after the player has built its DOM. The script in
-    // shell.ejs runs at page-load so #audio-element exists by the time
-    // this IIFE fires (script tag is below the body content).
-    const audio = document.getElementById('audio-element');
-    if (audio) {
-      ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
-        audio.addEventListener(ev, resyncAll);
-      });
-      // Initial state on page load (e.g. user navigated back while a track
-      // was already playing — buttons should reflect that).
-      resyncAll();
-    }
-  })();
-
-
-  // ── Bulk upload (drag-drop + sequential transcoding) ─────────
-  // Files dropped or picked are queued (not uploaded immediately) so the
-  // user can review the list, set shared metadata, then hit "Start upload".
-  // The loop POSTs one file at a time to /admin/audio/upload with
-  // Accept: application/json so the server returns structured per-file
-  // results instead of redirecting.
-  const dropzone   = document.getElementById('audio-dropzone');
-  const fileInput  = document.getElementById('audio-files');
-  const queueEl    = document.getElementById('upload-queue');
-  const actionsEl  = document.getElementById('upload-actions');
-  const startBtn   = document.getElementById('start-upload-btn');
-  const clearBtn   = document.getElementById('clear-queue-btn');
-  const artistInput= document.getElementById('batch-artist');
-  const albumInput = document.getElementById('batch-album');
-  const coverInput = document.getElementById('batch-cover');
-
-  /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
-  const queue = [];
-
-  function fmtBytes(n) {
-    if (n < 1024) return n + ' B';
-    if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
-    return (n / 1024 / 1024).toFixed(1) + ' MB';
-  }
-
-  function setItemStatus(item, status, label) {
-    const labels = {
-      queued:      (T.st_queued || ''),
-      uploading:   (T.st_uploading || ''),
-      transcoding: (T.st_transcoding || ''),
-      done:        '✓ ' + (T.st_done || ''),
-      error:       '✗ ' + (T.st_error || ''),
-    };
-    item.status = status;
-    const badge = item.el.querySelector('.ax-queue-status');
-    badge.className = 'ax-queue-status ax-queue-status--' + status;
-    badge.textContent = label || labels[status] || status;
-    // Restyle the row
-    item.el.classList.remove(
-      'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
-    );
-    if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
-    else if (status === 'done')  item.el.classList.add('ax-queue-item--done');
-    else if (status === 'error') item.el.classList.add('ax-queue-item--error');
-  }
-
-  function addFiles(files) {
-    let added = 0;
-    for (const file of files) {
-      if (!file.type.startsWith('audio/') &&
-          !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
-        // Silently skip non-audio drops; keeps the UX uncluttered.
-        continue;
-      }
-      const li = document.createElement('li');
-      li.className = 'ax-queue-item';
-      li.innerHTML =
-        '<div class="ax-queue-name"></div>' +
-        '<span class="ax-queue-status ax-queue-status--queued">' + (T.st_queued || '') + '</span>';
-      // Use textContent to avoid HTML-injection if a filename contains markup.
-      li.querySelector('.ax-queue-name').textContent = file.name;
-      // Append size hint inline
-      const size = document.createElement('span');
-      size.className = 'ax-queue-size';
-      size.textContent = fmtBytes(file.size);
-      li.querySelector('.ax-queue-name').appendChild(size);
-      queueEl.appendChild(li);
-      queue.push({ file, el: li, status: 'queued' });
-      added++;
-    }
-    if (added) {
-      queueEl.hidden = false;
-      actionsEl.hidden = false;
-    }
-  }
-
-  // ── Drop-zone events ─────────────────────────────────────────
-  // Page-level guard: a dropped file outside the zone would otherwise
-  // make the browser navigate to it (e.g. opening the audio inline), which
-  // discards typed metadata. We swallow drops anywhere unless the dropzone
-  // explicitly handles them.
-  ['dragover', 'drop'].forEach(ev => {
-    doc.on(window, ev, e => {
-      // Allow drops INSIDE the dropzone — its own listener handles those.
-      if (dropzone.contains(e.target)) return;
-      e.preventDefault();
-    });
-  });
-
-  ['dragenter', 'dragover'].forEach(ev => {
-    dropzone.addEventListener(ev, e => {
-      e.preventDefault();
-      dropzone.classList.add('is-dragover');
-    });
-  });
-  ['dragleave', 'drop'].forEach(ev => {
-    dropzone.addEventListener(ev, e => {
-      e.preventDefault();
-      dropzone.classList.remove('is-dragover');
-    });
-  });
-  dropzone.addEventListener('drop', e => {
-    if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
-  });
-  fileInput.addEventListener('change', () => {
-    addFiles(fileInput.files);
-    // Reset so the same file can be picked again later if user wants
-    fileInput.value = '';
-  });
-
-  // ── Clear queue button ───────────────────────────────────────
-  clearBtn.addEventListener('click', () => {
-    // Only remove items that aren't currently uploading (anything queued
-    // or already finished). An in-progress upload finishes, then its row
-    // would also disappear once we re-render — but we keep it simple and
-    // just refuse to clear during an active run.
-    if (startBtn.disabled) return;
-    queue.length = 0;
-    queueEl.innerHTML = '';
-    queueEl.hidden = true;
-    actionsEl.hidden = true;
-  });
-
-  // ── Sequential upload loop ───────────────────────────────────
-  startBtn.addEventListener('click', async () => {
-    if (startBtn.disabled) return;
-    startBtn.disabled = true;
-    clearBtn.disabled = true;
-    dropzone.style.pointerEvents = 'none';
-    dropzone.style.opacity = '0.5';
-
-    const sharedArtist = artistInput.value.trim();
-    const sharedAlbum  = albumInput.value.trim();
-    const sharedCover  = coverInput.files && coverInput.files[0];
-
-    // Process queued items one at a time. We iterate via index so that
-    // if more files get dropped during the run they ALSO get processed
-    // (queue.push above mutates the same array we're iterating).
-    for (let i = 0; i < queue.length; i++) {
-      const item = queue[i];
-      if (item.status !== 'queued') continue;
-      try {
-        await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
-      } catch (err) {
-        console.error('upload failed for', item.file.name, err);
-        setItemStatus(item, 'error', '✗ ' + (err.message || (T.failed || '')));
-      }
-    }
-
-    startBtn.disabled = false;
-    clearBtn.disabled = false;
-    dropzone.style.pointerEvents = '';
-    dropzone.style.opacity = '';
-
-    // Reload the page so the new tracks appear in the list below.
-    // Could also fetch them and inject, but a full reload is simpler and
-    // ensures position indexes / album-grouping are correct.
-    const anyDone = queue.some(q => q.status === 'done');
-    if (anyDone) {
-      setTimeout(() => location.reload(), 700);
-    }
-  });
-
-  async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
-    setItemStatus(item, 'uploading');
-
-    const fd = new FormData();
-    fd.append('audio', item.file);
-    if (sharedArtist) fd.append('artist', sharedArtist);
-    if (sharedAlbum)  fd.append('album',  sharedAlbum);
-    if (sharedCover)  fd.append('cover',  sharedCover);
-    // Title is intentionally omitted — server uses filename fallback.
-
-    // We can't reliably distinguish "still uploading bytes" from
-    // "uploading done, ffmpeg running" without progress events, but the
-    // status flips to "Converteren…" once the request is past upload phase.
-    // We approximate this by waiting until the response arrives — by then
-    // both phases are complete on the server side. For a smoother feel we
-    // briefly show "transcoding" near the end of the request lifecycle.
-    const transcodeHint = setTimeout(() => {
-      if (item.status === 'uploading') setItemStatus(item, 'transcoding');
-    }, 1500);
-
-    try {
-      const res = await fetch('/admin/audio/upload', {
-        method: 'POST',
-        headers: { 'Accept': 'application/json' },
-        body: fd,
-        credentials: 'same-origin',
-      });
-      clearTimeout(transcodeHint);
-
-      // Server responds with JSON for our Accept header. If it didn't
-      // (e.g. session expired and got an HTML login page), surface that.
-      let data;
-      try { data = await res.json(); }
-      catch (_) { throw new Error((T.err_unexpected || '') + ' (' + res.status + ')'); }
-
-      if (!res.ok || !data.ok) {
-        throw new Error(data.error || ('HTTP ' + res.status));
-      }
-
-      setItemStatus(item, 'done', '✓ ' + (data.title || (T.st_done || '')));
-    } catch (err) {
-      clearTimeout(transcodeHint);
-      throw err;
-    }
-  }
-
-  // ── Click-to-copy embed codes ─────────────────────────────────
-  document.querySelectorAll('[data-copy]').forEach(el => {
-    el.addEventListener('click', async () => {
-      const text = el.dataset.copy;
-      try {
-        await navigator.clipboard.writeText(text);
-        el.classList.add('is-copied');
-        const original = el.textContent;
-        el.textContent = '✓ ' + (T.copied || '');
-        setTimeout(() => {
-          el.classList.remove('is-copied');
-          el.textContent = original;
-        }, 1200);
-      } catch (_) { /* fall through — selection still works */ }
-    });
-  });
-
-  // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
-  const addLinkBtn = document.getElementById('add-link-track-btn');
-  if (addLinkBtn) {
-    addLinkBtn.addEventListener('click', async () => {
-      addLinkBtn.disabled = true;
-      try {
-        const r = await fetch('/admin/audio/create-link', {
-          method: 'POST', credentials: 'same-origin',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ title: (T.new_track || '') }),
-        });
-        const j = await r.json();
-        if (!r.ok || !j.ok) throw new Error(j.error || (T.create_failed || ''));
-        if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
-        window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
-      } catch (err) {
-        alert((T.create_failed || '') + ': ' + err.message);
-      } finally {
-        addLinkBtn.disabled = false;
-      }
-    });
-  }
-
-  // ── Wire all "Edit" buttons to the track-editor modal ─────────
-  // After save we patch the row in-place rather than reloading,
-  // so the user keeps their scroll position on long lists.
-  document.querySelectorAll('[data-track-edit]').forEach(btn => {
-    btn.addEventListener('click', () => {
-      if (typeof window.openTrackEditor !== 'function') {
-        alert((T.editor_not_loaded || ''));
-        return;
-      }
-      const id = btn.dataset.id;
-      window.openTrackEditor({
-        id,
-        onSaved: (track) => {
-          const row = document.querySelector('li[data-track-id="' + id + '"]');
-          if (!row) return;
-          // Update visible cells
-          const titleEl  = row.querySelector('[data-cell="title"]');
-          const artistEl = row.querySelector('[data-cell="artist"]');
-          const albumEl  = row.querySelector('[data-cell="album"]');
-          if (titleEl)  titleEl.textContent  = track.title  || (T.untitled || '');
-          if (artistEl) artistEl.textContent = track.artist || '—';
-          if (albumEl)  {
-            albumEl.textContent = track.album || '';
-            if (track.album) albumEl.removeAttribute('hidden');
-            else albumEl.setAttribute('hidden', '');
-          }
-          // Update cover thumb (replace element if type changed)
-          const oldThumb = row.querySelector('[data-cover-thumb]');
-          if (oldThumb) {
-            const parent = oldThumb.parentElement;
-            if (track.cover_url) {
-              const img = document.createElement('img');
-              img.className = 'ax-track-cover';
-              img.src = track.cover_url;
-              img.alt = '';
-              img.dataset.coverThumb = '';
-              parent.replaceChild(img, oldThumb);
-            } else {
-              const sp = document.createElement('span');
-              sp.className = 'ax-track-cover ax-track-cover-empty';
-              sp.textContent = '♫';
-              sp.dataset.coverThumb = '';
-              parent.replaceChild(sp, oldThumb);
-            }
-          }
-        },
-      });
-    });
-  });
-
-  // Download-voor-email toggle — AJAX (geen pagina-reload meer).
-  document.querySelectorAll('[data-track-dl]').forEach(btn => {
-    btn.addEventListener('click', async () => {
-      if (btn.disabled) return;
-      const want = btn.dataset.on === '1' ? 0 : 1;
-      btn.disabled = true;
-      try {
-        const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ downloadable: !!want }),
-        });
-        if (!res.ok) throw new Error('HTTP ' + res.status);
-        btn.dataset.on = String(want);
-        btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
-        btn.style.opacity = want ? '1' : '.5';
-        btn.title = want
-          ? (T.dl_on || '')
-          : (T.dl_off || '');
-      } catch (e) {
-        alert((T.change_failed || '') + ': ' + (e.message || e));
-      } finally {
-        btn.disabled = false;
-      }
-    });
-  });
-})();
-}
Index: src/assets/js/mod/admin-epk.js
===================================================================
--- src/assets/js/mod/admin-epk.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,27 +1,0 @@
-// Het EPK-scherm in beheer (pages/admin-epk.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-(function () {
-  var box = document.querySelector('.epk-track-pick');
-  if (!box) return;
-  var max = parseInt(box.dataset.max, 10) || 5;
-  function sync() {
-    var checks = box.querySelectorAll('input[type=checkbox]');
-    var n = box.querySelectorAll('input[type=checkbox]:checked').length;
-    checks.forEach(function (c) { c.disabled = (!c.checked && n >= max); });
-  }
-  box.addEventListener('change', sync);
-  sync();
-})();
-}
Index: src/assets/js/mod/admin-help.js
===================================================================
--- src/assets/js/mod/admin-help.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,36 +1,0 @@
-// Het hulpscherm in beheer (pages/admin-help.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-(function () {
-  var q = document.getElementById('hb-q');
-  var items = [].slice.call(document.querySelectorAll('.hb-item'));
-  var empty = document.getElementById('hb-empty');
-  var count = document.getElementById('hb-count');
-  if (!q) return;
-  // Bewaar de originele tekst per item (voor highlight-reset).
-  items.forEach(function (it) { it._txt = it.textContent.toLowerCase(); });
-  function run() {
-    var term = q.value.trim().toLowerCase();
-    var shown = 0;
-    items.forEach(function (it) {
-      var hit = !term || it._txt.indexOf(term) >= 0;
-      it.classList.toggle('hb-hidden', !hit);
-      if (hit) shown++;
-    });
-    empty.hidden = shown !== 0;
-    count.textContent = term ? (shown + ' onderwerp' + (shown === 1 ? '' : 'en') + ' gevonden') : '';
-  }
-  q.addEventListener('input', run);
-})();
-}
Index: src/assets/js/mod/admin-media.js
===================================================================
--- src/assets/js/mod/admin-media.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,45 +1,0 @@
-// Media in beheer -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
-// BINNEN de site binnenkomt (shaer-0i6). Servergegevens komen uit pageData();
-// interpolatie kan niet in een statisch bestand.
-
-import { pageData } from './lib.js';
-
-(function () {
-  if (window.__mediaWired) return; window.__mediaWired = true;
-  // Gedelegeerd op document, dus dit overleeft elke paginawissel -- ook op
-  // pagina's waar [data-copy] iets ANDERS betekent (audio en playlists: een
-  // shortcode, geen pad). Zonder deze wacht kaapt dit daar de kopieerklik en
-  // plakt er een origin voor (shaer-5s1). T per klik gelezen, want pageData
-  // wisselt mee met de pagina.
-  function active() { return (document.body.getAttribute('data-js') || '').split(/\s+/).indexOf('admin-media') !== -1; }
-  document.addEventListener('click', function (e) {
-    if (!active()) return;
-    var T = pageData();
-    var c = e.target.closest('[data-copy]');
-    if (c) {
-      var u = location.origin + c.getAttribute('data-copy');
-      var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); };
-      if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); });
-      else window.prompt('URL', u);
-      return;
-    }
-    var d = e.target.closest('[data-del]');
-    if (d) {
-      if (!window.confirm(T.delC)) return;
-      fetch('/admin/media/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) })
-        .then(function (r) { return r.json(); })
-        .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } else window.alert((j && j.error) || 'Error'); })
-        .catch(function () { window.alert('Error'); });
-      return;
-    }
-    if (e.target.closest('#media-cleanup')) {
-      if (!window.confirm(T.cleanC)) return;
-      fetch('/admin/media/cleanup', { method: 'POST', credentials: 'same-origin' })
-        .then(function (r) { return r.json(); })
-        .then(function (j) { location.href = '/admin/media?success=' + encodeURIComponent(((j && j.removed) || 0) + ' file(s) removed'); })
-        .catch(function () { window.alert('Error'); });
-    }
-  });
-})();
Index: src/assets/js/mod/admin-paid.js
===================================================================
--- src/assets/js/mod/admin-paid.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,27 +1,0 @@
-// Betaalde posts in beheer (pages/admin-paid.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-(function () {
-  var field = document.getElementById('pd-redirect');
-  var btn = document.getElementById('pd-copy');
-  if (!field || !btn) return;
-  field.addEventListener('focus', function () { field.select(); });
-  btn.addEventListener('click', function () {
-    field.select();
-    var done = function () { var t = btn.textContent; btn.textContent = document.getElementById('pd-copy').getAttribute('data-copied'); setTimeout(function () { btn.textContent = t; }, 1400); };
-    if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(field.value).then(done, function () { try { document.execCommand('copy'); done(); } catch (e) {} }); }
-    else { try { document.execCommand('copy'); done(); } catch (e) {} }
-  });
-})();
-}
Index: src/assets/js/mod/admin-playlists.js
===================================================================
--- src/assets/js/mod/admin-playlists.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,80 +1,0 @@
-// Afspeellijsten in beheer -- verplaatst uit inline script, shaer-bqr.
-//
-// Servergegevens (het csrf-token en drie teksten) komen uit pageData();
-// interpolatie kan niet in een statisch bestand.
-
-import { pageData } from './lib.js';
-
-// Element-bedrading per render, dus init() per paginawissel (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-(function() {
-  const _d = pageData();
-  const csrf = _d.csrf || '';
-
-  document.getElementById('pl-new-btn')?.addEventListener('click', () => {
-    if (typeof window.openPlaylistEditor === 'function') {
-      window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() });
-    }
-  });
-
-  // Click-to-copy on shortcodes
-  document.querySelectorAll('[data-copy]').forEach(el => {
-    el.addEventListener('click', async () => {
-      try {
-        await navigator.clipboard.writeText(el.dataset.copy);
-        el.classList.add('is-copied');
-        const original = el.textContent;
-        el.textContent = '✓ ' + (_d.copied || '');
-        setTimeout(() => { el.classList.remove('is-copied'); el.textContent = original; }, 1200);
-      } catch (_) { /* fall back to selection */ }
-    });
-  });
-
-  document.querySelectorAll('[data-pl-edit]').forEach(btn => {
-    btn.addEventListener('click', () => {
-      if (typeof window.openPlaylistEditor === 'function') {
-        window.openPlaylistEditor({ mode: 'edit', id: btn.dataset.id, onSaved: () => location.reload() });
-      }
-    });
-  });
-
-  document.querySelectorAll('[data-pl-delete]').forEach(btn => {
-    btn.addEventListener('click', async () => {
-      const id = btn.dataset.id;
-      const title = btn.dataset.title || id;
-      if (!confirm((_d.delConfirm || '').replace('{title}', title))) return;
-      try {
-        const r = await fetch(`/admin/playlists/api/${encodeURIComponent(id)}/delete`, {
-          method: 'POST',
-          headers: { 'X-CSRF-Token': csrf },
-          credentials: 'same-origin',
-        });
-        const j = await r.json();
-        if (j.ok) location.reload();
-        else alert((_d.delFailed || '') + ': ' + (j.error || ''));
-      } catch (err) {
-        alert((_d.delFailed || '') + ': ' + err.message);
-      }
-    });
-  });
-
-  // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor.
-  // openPlaylistEditor is defined synchronously by the included partial, so
-  // it's available by the time this IIFE runs.
-  (function deepLinkEdit() {
-    const params = new URLSearchParams(location.search);
-    const editId = params.get('edit');
-    if (!editId) return;
-    if (typeof window.openPlaylistEditor !== 'function') return;
-    // Strip the query param immediately so reload after save doesn't re-open.
-    history.replaceState({}, '', location.pathname);
-    window.openPlaylistEditor({
-      mode: 'edit',
-      id: editId,
-      onSaved: () => location.reload(),
-    });
-  })();
-})();
-}
Index: src/assets/js/mod/admin-push.js
===================================================================
--- src/assets/js/mod/admin-push.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,116 +1,0 @@
-// Pushmeldingen in beheer (pages/admin-push.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-(function () {
-  var cfg = JSON.parse(document.getElementById('np-data').textContent);
-  var elState = document.getElementById('np-state');
-  var btnOn = document.getElementById('np-on'), btnOff = document.getElementById('np-off'), btnTest = document.getElementById('np-test');
-  var alertsBox = document.getElementById('np-alerts'), savedMsg = document.getElementById('np-saved');
-  var currentEndpoint = null;
-
-  var isIos = /iPad|iPhone|iPod/.test(navigator.userAgent);
-  var standalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
-  if (isIos && !standalone) document.getElementById('np-ios-hint').hidden = false;
-
-  if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
-    document.getElementById('np-unsupported').hidden = false;
-    elState.textContent = cfg.i18n.unsupported;
-    return;
-  }
-
-  function b64ToU8(s) {
-    var pad = '='.repeat((4 - (s.length % 4)) % 4);
-    var raw = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/'));
-    var out = new Uint8Array(raw.length);
-    for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
-    return out;
-  }
-  function post(url, body) {
-    return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) });
-  }
-  function deviceLabel() {
-    var ua = navigator.userAgent;
-    var browser = /Firefox\//.test(ua) ? 'Firefox' : /Edg\//.test(ua) ? 'Edge' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
-    var os = /Android/.test(ua) ? 'Android' : /iPad|iPhone|iPod/.test(ua) ? 'iOS' : /Mac/.test(ua) ? 'macOS' : /Win/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : '';
-    return (browser + (os ? ' op ' + os : ''));
-  }
-  function readAlertBoxes() {
-    var out = {};
-    alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) { out[cb.getAttribute('data-alert')] = cb.checked ? 1 : 0; });
-    return out;
-  }
-  function setAlertBoxes(alerts) {
-    alertsBox.querySelectorAll('input[data-alert]').forEach(function (cb) {
-      cb.checked = !!alerts[cb.getAttribute('data-alert')];
-    });
-  }
-
-  function render(sub) {
-    currentEndpoint = sub ? sub.endpoint : null;
-    elState.textContent = sub ? cfg.i18n.on : (Notification.permission === 'denied' ? cfg.i18n.denied : cfg.i18n.off);
-    btnOn.hidden = !!sub || Notification.permission === 'denied';
-    btnOff.hidden = !sub;
-    btnTest.hidden = !sub;
-    alertsBox.hidden = !sub;
-  }
-
-  navigator.serviceWorker.ready.then(function (reg) {
-    return reg.pushManager.getSubscription();
-  }).then(function (sub) {
-    // Show this device's SAVED prefs when we know them, defaults otherwise.
-    setAlertBoxes((sub && cfg.saved[sub.endpoint]) ? Object.assign({}, cfg.alerts, cfg.saved[sub.endpoint]) : cfg.alerts);
-    render(sub);
-  }).catch(function () { elState.textContent = cfg.i18n.unknown; });
-
-  btnOn.addEventListener('click', function () {
-    btnOn.disabled = true;
-    Notification.requestPermission().then(function (perm) {
-      if (perm !== 'granted') { btnOn.disabled = false; render(null); return; }
-      navigator.serviceWorker.ready.then(function (reg) {
-        return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(cfg.vapid) });
-      }).then(function (sub) {
-        return post('/push/subscribe', { subscription: sub.toJSON(), alerts: readAlertBoxes(), uaLabel: deviceLabel() })
-          .then(function (r) { if (!r.ok) throw new Error('subscribe failed'); render(sub); location.reload(); });
-      }).catch(function () { btnOn.disabled = false; elState.textContent = cfg.i18n.failed; });
-    });
-  });
-
-  btnOff.addEventListener('click', function () {
-    navigator.serviceWorker.ready.then(function (reg) { return reg.pushManager.getSubscription(); }).then(function (sub) {
-      if (!sub) { render(null); return; }
-      var ep = sub.endpoint;
-      sub.unsubscribe().then(function () { return post('/push/unsubscribe', { endpoint: ep }); })
-        .then(function () { location.reload(); });
-    });
-  });
-
-  btnTest.addEventListener('click', function () {
-    btnTest.disabled = true;
-    post('/push/test').then(function () { setTimeout(function () { btnTest.disabled = false; }, 2000); });
-  });
-
-  alertsBox.addEventListener('change', function () {
-    if (!currentEndpoint) return;
-    post('/push/alerts', { endpoint: currentEndpoint, alerts: readAlertBoxes() }).then(function (r) {
-      if (r.ok) { savedMsg.hidden = false; setTimeout(function () { savedMsg.hidden = true; }, 1500); }
-    });
-  });
-
-  document.querySelectorAll('.np-remove').forEach(function (btn) {
-    btn.addEventListener('click', function () {
-      post('/push/unsubscribe', { endpoint: btn.getAttribute('data-endpoint') }).then(function () { location.reload(); });
-    });
-  });
-})();
-}
Index: src/assets/js/mod/admin-seo.js
===================================================================
--- src/assets/js/mod/admin-seo.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,158 +1,0 @@
-// De SEO-pagina: op dit moment alleen het MusicBrainz-paneel (shaer-mbz).
-//
-// De rest van dit scherm is gewone formuliervelden en heeft geen JavaScript
-// nodig; deze module bestaat omdat opzoeken dat wel doet.
-
-import { pageData, makeSweeper } from './lib.js';
-
-const doc = makeSweeper();
-let T = {};
-
-export function init() {
-  doc.sweep();
-  T = pageData();
-  run();
-}
-
-function run() {
-  // Binnen run() en niet op moduleniveau: deze modules krijgen bij elke
-  // paginawissel opnieuw init(), en een blok dat maar een keer per sessie
-  // draait is precies wat shaer-5s1 opleverde.
-  wireMusicBrainz();
-}
-
-/**
- * "Zoek jezelf op" in MusicBrainz.
- *
- * De kandidaten komen van de server, want MusicBrainz eist een verzoek per
- * seconde per APPLICATIE en een User-Agent met contact -- allebei niet vanuit
- * een browser af te dwingen.
- *
- * WIJ KIEZEN NIET. Ook niet als er precies een treffer is: een verkeerd geraden
- * MBID zet jouw naam onder andermans werk. De knop staat er, de klik is van de
- * artiest. En de keuze gaat mee met de Opslaan van de pagina -- een eigen
- * formulier kan hier niet, want deze pagina IS er een.
- */
-function wireMusicBrainz() {
-  const knop = document.getElementById('mb-zoek-btn');
-  const veld = document.getElementById('mb-q');
-  const uit = document.getElementById('mb-uit');
-  const idVeld = document.getElementById('mb-id');
-  const naamVeld = document.getElementById('mb-naam');
-  const huidig = document.getElementById('mb-huidig');
-  if (!knop || !veld || !uit || !idVeld || knop.__wired) return;
-  knop.__wired = true;
-
-  const el = (tag, cls, tekst) => {
-    const e = document.createElement(tag);
-    if (cls) e.className = cls;
-    // textContent en nooit innerHTML: dit is tekst uit een vreemd register.
-    if (tekst != null) e.textContent = tekst;
-    return e;
-  };
-
-  function zet(mbid, naam) {
-    idVeld.value = mbid || '';
-    if (naamVeld) naamVeld.value = naam || '';
-    if (huidig) {
-      huidig.hidden = !mbid;
-      const n = document.getElementById('mb-huidig-naam');
-      const a = document.getElementById('mb-huidig-link');
-      if (n) n.textContent = naam || mbid || '';
-      if (a) a.href = mbid ? `https://musicbrainz.org/artist/${mbid}` : '#';
-    }
-    uit.hidden = true;
-    uit.replaceChildren();
-    checkTerugweg(mbid);
-  }
-
-  const wis = document.getElementById('mb-wis');
-  if (wis) wis.addEventListener('click', () => zet('', ''));
-
-  // DE TERUG-WEG. Wij zetten hem niet -- dat kan niet via hun API en hoort ook
-  // niet -- we kijken alleen of de artiestenpagina ons domein noemt. Zolang dat
-  // niet zo is staat er "nog eenzijdig", want een halve koppeling die als
-  // bevestigd leest is het soort stilte waar we vandaag al genoeg van zagen.
-  const terugweg = document.getElementById('mb-terugweg');
-  async function checkTerugweg(mbid) {
-    if (!terugweg) return;
-    if (!mbid) { terugweg.hidden = true; terugweg.textContent = ''; return; }
-    terugweg.hidden = false;
-    terugweg.className = 'mb-terugweg';
-    terugweg.textContent = T.mb_checking || 'Terug-weg controleren…';
-    try {
-      const r = await fetch(`/admin/seo/api/musicbrainz/terugweg?mbid=${encodeURIComponent(mbid)}`, { credentials: 'same-origin' });
-      const j = await r.json();
-      terugweg.className = 'mb-terugweg ' + (j.verified ? 'is-ok' : 'is-eenzijdig');
-      terugweg.textContent = j.verified
-        ? (T.mb_verified || 'Wederzijds.')
-        : (T.mb_unverified || 'Nog eenzijdig.');
-    } catch {
-      terugweg.hidden = true;
-    }
-  }
-  // Meteen bij het openen, want de artiest kan hem intussen op MusicBrainz
-  // hebben gezet en hoort dat hier te zien zonder opnieuw te koppelen.
-  if (idVeld.value) checkTerugweg(idVeld.value);
-
-  async function zoek() {
-    const q = (veld.value || '').trim();
-    if (!q) return;
-    uit.hidden = false;
-    uit.replaceChildren(el('p', 'form-hint', T.mb_busy || 'Zoeken…'));
-    knop.disabled = true;
-    try {
-      const r = await fetch(`/admin/seo/api/musicbrainz?q=${encodeURIComponent(q)}`, { credentials: 'same-origin' });
-      const j = await r.json();
-      toon((j && j.kandidaten) || []);
-    } catch {
-      uit.replaceChildren(el('p', 'form-hint', T.mb_fail || 'MusicBrainz is even niet bereikbaar.'));
-    } finally {
-      knop.disabled = false;
-    }
-  }
-
-  function toon(kandidaten) {
-    if (!kandidaten.length) {
-      uit.replaceChildren(el('p', 'form-hint', T.mb_none || 'Niets gevonden.'));
-      return;
-    }
-    const lijst = el('ul', 'mb-lijst');
-    for (const k of kandidaten) {
-      const li = el('li', 'mb-kandidaat');
-
-      // Regel voor regel, want dit is een KEUZE en geen opsomming. Alles op een
-      // rij duwen maakt de toelichting -- juist het veld dat de bands uit
-      // elkaar houdt -- tot bijzin achter de naam.
-      li.appendChild(el('span', 'mb-naam', k.naam));
-
-      // De toelichting is het hele punt: er zijn drie bands die Nirvana heten,
-      // en zonder dit veld kiest iemand de verkeerde. Eigen regel dus.
-      if (k.toelichting) li.appendChild(el('span', 'mb-toelichting', k.toelichting));
-
-      // De feiten eronder, en alleen wat er IS. Een lege bullet tussen twee
-      // punten leest als ontbrekende informatie in plaats van als afwezige.
-      const feiten = [k.soort, k.land, k.jaren].filter(Boolean).join(' · ');
-      if (feiten) li.appendChild(el('span', 'mb-feiten', feiten));
-
-      const voet = el('div', 'mb-voet');
-      const open = el('a', 'mb-open', T.mb_open || 'Bekijk op MusicBrainz');
-      open.href = k.url; open.target = '_blank'; open.rel = 'noopener';
-      voet.appendChild(open);
-      // Hun eigen score. Alleen tonen als hij ZWAK is: bij een goede treffer is
-      // een getal ruis, bij een zwakke is het een waarschuwing.
-      if (k.score && k.score < 70) voet.appendChild(el('span', 'mb-zwak', `${k.score}%`));
-      const b = el('button', 'btn mb-kies', T.mb_pick || 'Dit ben ik');
-      b.type = 'button';
-      b.addEventListener('click', () => zet(k.mbid, k.naam));
-      voet.appendChild(b);
-      li.appendChild(voet);
-
-      lijst.appendChild(li);
-    }
-    uit.replaceChildren(lijst);
-  }
-
-  knop.addEventListener('click', zoek);
-  veld.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); zoek(); } });
-}
Index: src/assets/js/mod/admin-settings.js
===================================================================
--- src/assets/js/mod/admin-settings.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,25 +1,0 @@
-// Instellingen in beheer (pages/admin-settings.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-    (function () {
-      var f = document.getElementById('mode-form');
-      if (!f || f.__age18wired) return; f.__age18wired = true;
-      f.addEventListener('submit', function (e) {
-        var sel = f.querySelector('input[name=mode]:checked');
-        if (sel && sel.value === 'cirkels' && f.getAttribute('data-was-cirkels') === '0') {
-          if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault();
-        }
-      });
-    })();
-}
Index: src/assets/js/mod/admin-site-edit.js
===================================================================
--- src/assets/js/mod/admin-site-edit.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,136 +1,0 @@
-// Site bewerken in beheer (pages/admin-site-edit.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-    (function(){
-      if (window.__themePreviewWired) return; window.__themePreviewWired = true;
-      var html = document.documentElement;
-      function applyAccent(hex){
-        if(!/^#[0-9a-fA-F]{6}$/.test(hex)) return;
-        var sa = document.getElementById('pcms-site-accent');
-        if(!sa){ sa = document.createElement('style'); sa.id = 'pcms-site-accent'; document.head.appendChild(sa); }
-        sa.textContent = ':root,[data-palette]{--accent:'+hex+';--accent-soft:color-mix(in srgb,'+hex+' 80%,white);--accent-tint:color-mix(in srgb,'+hex+' 12%,transparent);}';
-      }
-      document.addEventListener('change', function(e){
-        var t = e.target; if(!t || !t.name) return;
-        if(t.name === 'palette'){ html.setAttribute('data-palette', t.value); }
-        else if(t.name === 'accent'){ applyAccent(t.value); }
-        else if(t.name === 'theme_override'){
-          if(t.value === 'light' || t.value === 'dark'){ html.setAttribute('data-theme', t.value); }
-          else { var dk = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; html.setAttribute('data-theme', dk ? 'dark' : 'light'); }
-        }
-      });
-    })();
-    
-
-// ── volgend blok ──
-
-(function() {
-  // Slug URL field: show the real host as a dimmed prefix (the slug itself is
-  // coloured via CSS). Hub → host/user/<slug>, otherwise host/<slug>.
-  var sh = document.getElementById('slug-host');
-  if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/');
-})();
-(function() {
-  // Profile-links repeater: add row from <template>, remove on click.
-  var rows = document.getElementById('profile-links-rows');
-  var tpl  = document.getElementById('profile-link-template');
-  var add  = document.getElementById('profile-link-add');
-  if (!rows || !tpl || !add) return;
-
-  add.addEventListener('click', function() {
-    var clone = tpl.content.cloneNode(true);
-    rows.appendChild(clone);
-  });
-  rows.addEventListener('click', function(e) {
-    if (e.target && e.target.classList.contains('pl-remove')) {
-      var row = e.target.closest('.profile-link-row');
-      if (row) row.remove();
-    }
-  });
-})();
-
-// P63 — Profile photo picker: upload via /admin/sites/upload-photo,
-// then write the returned URL into the visible input. Live thumb preview.
-(function() {
-  var picker  = document.getElementById('photo-picker');
-  if (!picker) return;
-  var thumb   = document.getElementById('photo-thumb');
-  var preview = document.getElementById('photo-preview');
-  var urlEl   = document.getElementById('photo-url');
-  var trigger = document.getElementById('photo-upload-trigger');
-  var clear   = document.getElementById('photo-clear');
-  var field   = document.getElementById('photo-upload-field');
-  var status  = document.getElementById('photo-status');
-
-  function showPreview(url) {
-    if (url) {
-      preview.src = url;
-      preview.hidden = false;
-      thumb.removeAttribute('data-empty');
-      clear.hidden = false;
-    } else {
-      preview.src = '';
-      preview.hidden = true;
-      thumb.setAttribute('data-empty', '');
-      clear.hidden = true;
-    }
-  }
-
-  if (urlEl) {
-    urlEl.addEventListener('input', function() {
-      showPreview(urlEl.value.trim());
-    });
-  }
-
-  if (trigger && field) {
-    trigger.addEventListener('click', function() { field.click(); });
-    field.addEventListener('change', async function() {
-      var file = field.files && field.files[0];
-      if (!file) return;
-      status.classList.remove('is-error');
-      status.textContent = 'Uploaden…';
-      try {
-        var fd = new FormData();
-        fd.append('photo', file);
-        var r = await fetch('/admin/sites/upload-photo', {
-          method: 'POST',
-          body: fd,
-          credentials: 'same-origin',
-        });
-        var j = await r.json();
-        if (!r.ok || !j.ok) throw new Error(j.error || ('Upload mislukt (' + r.status + ')'));
-        urlEl.value = j.url;
-        showPreview(j.url);
-        status.textContent = 'Geüpload ✓';
-        setTimeout(function() { status.textContent = ''; }, 2000);
-      } catch (e) {
-        status.classList.add('is-error');
-        status.textContent = 'Mislukt: ' + e.message;
-      } finally {
-        field.value = '';
-      }
-    });
-  }
-
-  if (clear) {
-    clear.addEventListener('click', function() {
-      urlEl.value = '';
-      showPreview('');
-      status.textContent = '';
-      // Note: doesn't delete the file from disk — saving the form with empty
-      // URL leaves the file orphaned on the server. Acceptable for now.
-    });
-  }
-})();
-}
Index: src/assets/js/mod/admin-videos.js
===================================================================
--- src/assets/js/mod/admin-videos.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,37 +1,0 @@
-// Video's in beheer -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
-// BINNEN de site binnenkomt (shaer-0i6). Servergegevens komen uit pageData();
-// interpolatie kan niet in een statisch bestand.
-
-import { pageData } from './lib.js';
-
-(function () {
-  if (window.__videosWired) return; window.__videosWired = true;
-  // Gedelegeerd op document, dus dit overleeft elke paginawissel -- ook op
-  // pagina's waar [data-copy] iets ANDERS betekent (audio en playlists: een
-  // shortcode, geen pad). Zonder deze wacht kaapt dit daar de kopieerklik en
-  // plakt er een origin voor (shaer-5s1). T per klik gelezen, want pageData
-  // wisselt mee met de pagina.
-  function active() { return (document.body.getAttribute('data-js') || '').split(/\s+/).indexOf('admin-videos') !== -1; }
-  document.addEventListener('click', function (e) {
-    if (!active()) return;
-    var T = pageData();
-    var c = e.target.closest('[data-copy]');
-    if (c) {
-      var u = location.origin + c.getAttribute('data-copy');
-      var done = function () { var o = c.textContent; c.textContent = '✓'; setTimeout(function () { c.textContent = o === '✓' ? T.copy : o; }, 1200); };
-      if (navigator.clipboard) navigator.clipboard.writeText(u).then(done).catch(function () { window.prompt('URL', u); });
-      else window.prompt('URL', u);
-      return;
-    }
-    var d = e.target.closest('[data-del]');
-    if (d) {
-      if (!window.confirm(T.delC)) return;
-      fetch('/admin/media/videos/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: d.getAttribute('data-del') }) })
-        .then(function (r) { return r.json(); })
-        .then(function (j) { if (j && j.ok) { var card = d.closest('.media-card'); if (card) card.remove(); } })
-        .catch(function () {});
-    }
-  });
-})();
Index: src/assets/js/mod/auth-register.js
===================================================================
--- src/assets/js/mod/auth-register.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,25 +1,0 @@
-// Het registratieformulier (pages/auth-register.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-(function () {
-  var u = document.getElementById('setup-username'), out = document.getElementById('setup-handle');
-  if (!u || !out) return;
-  function upd() {
-    var v = (u.value || '').toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'username';
-    out.textContent = '@' + v + '@' + location.host;
-  }
-  u.addEventListener('input', upd);
-  upd();
-})();
-}
Index: src/assets/js/mod/authorize-interaction.js
===================================================================
--- src/assets/js/mod/authorize-interaction.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,68 +1,0 @@
-// Interactie autoriseren (pages/authorize-interaction.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-    (function () {
-      // Element-vlag, geen window-vlag: na een swap is de knop een NIEUW element
-      // en moet hij opnieuw bedraad; een window-vlag zou dat voorgoed blokkeren.
-      var a = document.getElementById('fedi-bm-btn'); if (!a || a.__wired) return; a.__wired = true;
-      a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
-      a.addEventListener('click', function (e) { e.preventDefault(); a.classList.add('nudge'); setTimeout(function(){ a.classList.remove('nudge'); }, 600); });
-    })();
-    
-
-// ── volgend blok ──
-
-      (function () {
-        if (window.__fediEditWired) return; window.__fediEditWired = true;
-        document.addEventListener('click', function (e) {
-          var b = e.target.closest && e.target.closest('.fedi-edit-btn');
-          if (!b) return;
-          var li = b.closest('.fedi-manage-item'); if (!li) return;
-          var form = li.querySelector('.fedi-edit-form'); if (!form) return;
-          var open = form.classList.toggle('is-open');
-          b.classList.toggle('is-open', open);
-          b.setAttribute('aria-expanded', open ? 'true' : 'false');
-          if (open) { var ed = form.querySelector('.re-editor') || form.querySelector('textarea'); if (ed) ed.focus(); }
-        });
-      })();
-      
-
-// ── volgend blok ──
-
-/* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page. */
-(function(){
-  if (window.__fediReactWired) return; window.__fediReactWired = true;
-  document.addEventListener('submit', function(e){
-    var f = e.target.closest && e.target.closest('.fedi-react-form');
-    if (!f) return;
-    e.preventDefault();
-    var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
-    btn.disabled = true;
-    var body = new URLSearchParams();
-    new FormData(f).forEach(function(v, k){ body.append(k, v); });
-    fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
-      .then(function(r){ return r.ok ? r.json() : null; })
-      .then(function(j){
-        if (j) {
-          var on = !!j.on;
-          btn.classList.toggle('is-on', on);
-          var lbl = btn.querySelector('.fedi-bigact-label');
-          if (lbl) lbl.textContent = on ? (btn.getAttribute('data-on') || lbl.textContent) : (btn.getAttribute('data-off') || lbl.textContent);
-        }
-      })
-      .catch(function(){})
-      .then(function(){ btn.disabled = false; });
-  });
-})();
-}
Index: src/assets/js/mod/chrome.js
===================================================================
--- src/assets/js/mod/chrome.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,284 +1,0 @@
-// De chrome: alles wat om elke pagina heen staat (shaer-bqr).
-//
-// Dit stond als inline <script> in de partials. Dat kan niet blijven: de
-// CSP-nonce rouleert per verzoek, en de chrome wordt bij ELKE htmx-navigatie
-// out-of-band opnieuw ingevoegd. Zo'n script draagt dan een nonce die het
-// document niet kent en wordt geweigerd -- dus viel de chrome-JS bij de eerste
-// klik binnen de site al weg (shaer-0i6).
-//
-// Nu een module, geladen door de bootstrap in shell.ejs. Die zit in het
-// document zelf, heeft dus wel de goede nonce, en een dynamische import vanuit
-// een vertrouwd script is precies waar 'strict-dynamic' voor is.
-//
-// TWEE REGELS voor alles wat hier bij komt:
-//
-//   GEDELEGEERD  luister op document, nooit op een element dat er nu staat. De
-//                chrome wordt vervangen, dus een vastgehouden verwijzing is na
-//                een navigatie een verwijzing naar iets dat weg is.
-//   IDEMPOTENT   de module kan een tweede keer geladen worden. Een slot op
-//                window voorkomt dat er een tweede stel luisteraars bij komt --
-//                dat is hoe de themaknop ooit twee keer vuurde en dus niets deed.
-//
-// En: GEEN servergegevens in deze code. Interpolatie hoort niet in een statisch
-// bestand; wat de server wil meegeven komt via een data-attribuut op een element.
-
-(function () {
-  'use strict';
-  if (window.__chromeMod) return;
-  window.__chromeMod = true;
-
-  // De zoekknop in de bottom-tab opent de overlay. De overlay zelf wordt bij een
-  // htmx-navigatie vervangen, dus hem hier vasthouden zou na een klik binnen de
-  // site niets meer opleveren: elke keer opnieuw opzoeken.
-  document.addEventListener('click', function (e) {
-    if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return;
-    var o = document.getElementById('search-overlay');
-    if (o) {
-      o.hidden = false;
-      var i = o.querySelector('input');
-      if (i) i.focus();
-    }
-  });
-})();
-
-// ── de profielkop ──────────────────────────────────────────────
-// Zat inline in partials/profile-header.ejs, en die partial zit IN de
-// OOB-chrome -- dus hij kwam bij elke navigatie opnieuw binnen.
-(function () {
-  if (window.__pcmsFediFollowWired) return;
-  window.__pcmsFediFollowWired = true;
-  function modal() { return document.querySelector('.pf-follow'); }
-  function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
-  function go() {
-    var m = modal(); if (!m) return;
-    var inp = m.querySelector('.pf-follow-input'), raw = inp ? inp.value : '';
-    // Strip a full @user@host handle (and any scheme/path) down to the server host.
-    var server = String(raw || '').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
-    if (!server) { if (inp) inp.focus(); return; }
-    try { localStorage.setItem('pcmsFediServer', server); } catch (e) {}
-    var actor = location.origin + '/ap/users/' + encodeURIComponent(m.getAttribute('data-actor-slug') || '');
-    location.href = 'https://' + server + '/authorize_interaction?uri=' + encodeURIComponent(actor);
-  }
-  document.addEventListener('click', function (e) {
-    var b = e.target.closest && e.target.closest('.profile-fedi-link');
-    if (b) { e.preventDefault();
-      var m = modal(); if (!m) return;
-      m.setAttribute('data-actor-slug', b.getAttribute('data-fedi-actor-slug') || '');
-      var inp = m.querySelector('.pf-follow-input');
-      try { if (inp && !inp.value) inp.value = localStorage.getItem('pcmsFediServer') || ''; } catch (e2) {}
-      m.classList.add('is-open');
-      setTimeout(function () { if (inp) inp.focus(); }, 30);
-      return;
-    }
-    if (e.target.closest && e.target.closest('.pf-follow-go')) { e.preventDefault(); go(); return; }
-    if (e.target.closest && e.target.closest('.pf-follow-cancel')) { e.preventDefault(); closeM(); return; }
-    var open = document.querySelector('.pf-follow.is-open');
-    if (open && e.target === open) closeM(); /* click on backdrop */
-  });
-  document.addEventListener('keydown', function (e) {
-    var m = document.querySelector('.pf-follow.is-open'); if (!m) return;
-    if (e.key === 'Escape') { closeM(); }
-    else if (e.key === 'Enter' && e.target.closest && e.target.closest('.pf-follow')) { e.preventDefault(); go(); }
-  });
-})();
-
-/* Profile summary: click the avatar to open a modal with photo + details. */
-(function () {
-  if (window.__pcmsLightboxWired) return; window.__pcmsLightboxWired = true;
-  function close() { var m = document.querySelector('.pf-summary.is-open'); if (m) m.classList.remove('is-open'); }
-  document.addEventListener('click', function (e) {
-    if (e.target.closest && e.target.closest('.pf-summary-close')) { e.preventDefault(); close(); return; }
-    var open = document.querySelector('.pf-summary.is-open');
-    if (open && e.target === open) { close(); return; } /* click on backdrop */
-    var t = e.target.closest && e.target.closest('.profile-photo[data-pf-summary]');
-    if (!t) return;
-    e.preventDefault();
-    var root = (t.closest && t.closest('#pcms-chrome')) || document;
-    var modal = root.querySelector('.pf-summary') || document.querySelector('.pf-summary');
-    if (modal) modal.classList.add('is-open');
-  });
-  document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
-})();
-
-// ── de topnav ──────────────────────────────────────────────────
-// Zat inline in partials/topnav.ejs. De zoekteksten komen nu van het
-// overlay-element (data-i18n) in plaats van uit interpolatie.
-(function() {
-  // Wired ONCE. This chrome (topnav) is re-inserted out-of-band during htmx navigation
-  // → without this guard the script would stack EXTRA listeners on every navigation,
-  // causing the theme toggle to fire 2× (or more) = no net change ("toggle stops working").
-  // Everything below uses event delegation on body/document, so it also works for
-  // buttons that appear after this run (OOB).
-  if (window.__pcmsChromeWired) return;
-  window.__pcmsChromeWired = true;
-
-  function toggleTheme() {
-    var cur = document.documentElement.getAttribute('data-theme') || 'dark';
-    var next = cur === 'dark' ? 'light' : 'dark';
-    document.documentElement.setAttribute('data-theme', next);
-    try { localStorage.setItem('pcms-theme', next); } catch (e) {}
-  }
-  function overlay() { return document.getElementById('search-overlay'); }
-  function openSearch() { var o = overlay(); if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); } }
-  function closeSearch() { var o = overlay(); if (o) { o.hidden = true; var b = document.getElementById('search-suggest'); if (b) b.innerHTML = ''; } }
-
-  document.body.addEventListener('click', function(e) {
-    if (e.target.closest('#theme-toggle, #theme-toggle-mobile, #theme-toggle-footer')) { toggleTheme(); return; }
-    if (e.target.closest('#search-toggle')) { openSearch(); return; }
-    if (e.target.closest('#search-close')) { closeSearch(); return; }
-    // Close open dropdowns (user menu + language picker) on click outside.
-    document.querySelectorAll('.user-menu[open], .lang-menu[open]').forEach(function(d) {
-      if (!d.contains(e.target)) d.removeAttribute('open');
-    });
-  });
-
-  document.addEventListener('keydown', function(e) {
-    if (e.key === 'Escape') { var o = overlay(); if (o && !o.hidden) closeSearch(); }
-  });
-
-  // ── Live results while typing ───────────────────────────────────
-  // De teksten komen van de overlay zelf, elke keer opnieuw: bij een
-  // htmx-navigatie wordt die vervangen en kan de taal gewisseld zijn.
-  function _st() {
-    var o = document.getElementById('search-overlay');
-    try { return JSON.parse((o && o.getAttribute('data-i18n')) || '{}'); } catch (e) { return {}; }
-  }
-  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
-  function renderSuggest(box, d, q, ov) {
-    var html = '';
-    function grp(title, items, fmt) {
-      if (!items || !items.length) return;
-      html += '<div class="ss-group"><div class="ss-title">' + esc(title) + '</div>' + items.map(fmt).join('') + '</div>';
-    }
-    grp(_st().posts, d.posts, function(p){ return '<a class="ss-item" href="' + esc(p.url) + '">' + esc(p.title) + '</a>'; });
-    grp(_st().tracks, d.tracks, function(tk){ var sub = tk.artist ? ' <span class="ss-sub">' + esc(tk.artist) + '</span>' : ''; return tk.url ? '<a class="ss-item" href="' + esc(tk.url) + '">' + esc(tk.title) + sub + '</a>' : '<span class="ss-item ss-noclick">' + esc(tk.title) + sub + '</span>'; });
-    grp(_st().events, d.events, function(ev){ return '<a class="ss-item" href="' + esc(ev.url) + '">' + esc(ev.where || ev.when) + ' <span class="ss-sub">' + esc(ev.when) + '</span></a>'; });
-    grp(_st().pages, d.pages, function(pg){ return '<a class="ss-item" href="' + esc(pg.url) + '">' + esc(pg.label) + '</a>'; });
-    var hasAny = (d.posts && d.posts.length) || (d.tracks && d.tracks.length) || (d.events && d.events.length) || (d.pages && d.pages.length);
-    if (!hasAny) { box.innerHTML = '<div class="ss-empty">' + esc(_st().none) + '</div>'; return; }
-    var action = (ov && ov.getAttribute('data-action')) || '/search';
-    html += '<a class="ss-all" href="' + esc(action) + '?q=' + encodeURIComponent(q) + '">' + esc(_st().all) + '</a>';
-    box.innerHTML = html;
-  }
-  var _sTimer;
-  document.addEventListener('input', function(e) {
-    var inp = e.target.closest && e.target.closest('#search-overlay input[name="q"]');
-    if (!inp) return;
-    var box = document.getElementById('search-suggest');
-    if (!box) return;
-    var q = inp.value.trim();
-    clearTimeout(_sTimer);
-    if (q.length < 2) { box.innerHTML = ''; return; }
-    _sTimer = setTimeout(function() {
-      var ov = document.getElementById('search-overlay');
-      var url = (ov && ov.getAttribute('data-suggest')) || '/search/suggest';
-      fetch(url + '?q=' + encodeURIComponent(q))
-        .then(function(r){ return r.ok ? r.json() : null; })
-        .then(function(d){ if (d) renderSuggest(box, d, q, ov); })
-        .catch(function(){});
-    }, 200);
-  });
-  // Click on a suggestion → close the overlay (the link/boost handles navigation).
-  document.addEventListener('click', function(e) {
-    if (e.target.closest && e.target.closest('#search-suggest a')) { var o = overlay(); if (o) o.hidden = true; }
-  });
-})();
-
-// ── het profielblad ────────────────────────────────────────────
-// Zat inline in partials/profile-sheet.ejs, dat de shell opneemt.
-(function() {
-  const sheet = document.getElementById('profile-sheet');
-  if (!sheet) return;
-
-  const backdrop  = document.getElementById('profile-sheet-backdrop');
-  const panel     = sheet.querySelector('.profile-sheet-panel');
-  const closeBtn  = document.getElementById('profile-sheet-close');
-  const dragZone  = document.getElementById('profile-sheet-drag-zone');
-  const themeBtn  = document.getElementById('profile-sheet-theme');
-  const themeLbl  = document.getElementById('profile-sheet-theme-state');
-
-  function openSheet() {
-    sheet.classList.add('is-open');
-    sheet.setAttribute('aria-hidden', 'false');
-    document.body.classList.add('profile-sheet-locked');
-    syncTheme();
-  }
-  function closeSheet() {
-    sheet.classList.remove('is-open');
-    sheet.setAttribute('aria-hidden', 'true');
-    document.body.classList.remove('profile-sheet-locked');
-    panel.style.removeProperty('--pcms-drag-y');
-  }
-
-  // Open: any element with [data-profile-sheet-toggle]
-  document.addEventListener('click', function(e) {
-    const trigger = e.target.closest('[data-profile-sheet-toggle]');
-    if (trigger) {
-      e.preventDefault();
-      openSheet();
-    }
-  });
-
-  // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item
-  if (backdrop) backdrop.addEventListener('click', closeSheet);
-  if (closeBtn) closeBtn.addEventListener('click', closeSheet);
-  document.addEventListener('keydown', (e) => {
-    if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
-  });
-
-  // Menu items that navigate: close synchronously before nav fires
-  sheet.querySelectorAll('[data-close-sheet]').forEach((el) => {
-    el.addEventListener('click', closeSheet);
-  });
-
-  // Drag-down-to-close on touch.
-  // Uses --pcms-drag-y custom property rather than overwriting the panel's
-  // transform string. This composes correctly with any horizontal centering
-  // (currently none here, but the pattern matches audio-sheet for safety).
-  let startY = 0, lastY = 0, dragging = false;
-  function onDown(e) {
-    if (e.pointerType !== 'touch') return;
-    if (panel.scrollTop > 0) return;
-    startY = lastY = e.clientY;
-    dragging = true;
-    panel.classList.add('is-dragging');
-  }
-  function onMove(e) {
-    if (!dragging) return;
-    lastY = e.clientY;
-    const dy = Math.max(0, lastY - startY);
-    panel.style.setProperty('--pcms-drag-y', dy + 'px');
-  }
-  function onUp() {
-    if (!dragging) return;
-    dragging = false;
-    panel.classList.remove('is-dragging');
-    const dy = lastY - startY;
-    if (dy > 80) closeSheet();
-    else panel.style.removeProperty('--pcms-drag-y');
-  }
-  if (window.PointerEvent && dragZone) {
-    dragZone.addEventListener('pointerdown', onDown);
-    document.addEventListener('pointermove', onMove);
-    document.addEventListener('pointerup', onUp);
-    document.addEventListener('pointercancel', onUp);
-  }
-
-  // Theme toggle inside sheet — syncs label
-  function syncTheme() {
-    if (!themeLbl) return;
-    const t = document.documentElement.getAttribute('data-theme') || 'dark';
-    // De twee labels staan op het element zelf: een module kan geen vertaling
-    // interpoleren, en zo hoort de tekst bij het ding dat hem toont.
-    themeLbl.textContent = themeLbl.getAttribute(t === 'dark' ? 'data-dark' : 'data-light') || themeLbl.textContent;
-  }
-  if (themeBtn) {
-    themeBtn.addEventListener('click', function() {
-      const cur = document.documentElement.getAttribute('data-theme') || 'dark';
-      const next = cur === 'dark' ? 'light' : 'dark';
-      document.documentElement.setAttribute('data-theme', next);
-      try { localStorage.setItem('pcms-theme', next); } catch (e) {}
-      syncTheme();
-    });
-  }
-})();
Index: src/assets/js/mod/download.js
===================================================================
--- src/assets/js/mod/download.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,16 +1,0 @@
-// De downloadpagina -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
-// BINNEN de site binnenkomt (shaer-0i6). Wat de server meegeeft komt uit
-// pageData(); interpolatie kan niet in een statisch bestand.
-
-import { pageData } from './lib.js';
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-export function init() { run(); }
-
-function run() {
-        // Auto-start de download (zelfde-origin attachment-link).
-        setTimeout(function(){ try { var u = pageData().fileUrl; if (u) window.location.href = u; } catch(e){} }, 600);
-}
Index: src/assets/js/mod/lib.js
===================================================================
--- src/assets/js/mod/lib.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,106 +1,0 @@
-// Gedeeld gereedschap voor de pagina-modules (shaer-bqr).
-
-/**
- * De servergegevens van deze pagina.
- *
- * Een module is een statisch bestand, dus er kan geen EJS in. Wat het script van
- * de server nodig heeft -- vertalingen, ids, instellingen -- zet de pagina in
- * een <script type="application/json"> via partials/page-data.ejs, en dit leest
- * het terug.
- *
- * ELKE KEER OPNIEUW LEZEN en niet eenmalig onthouden: bij een htmx-navigatie
- * wisselt de inhoud terwijl de module blijft leven, dus een gegevensblok dat je
- * bij het laden vasthoudt is een pagina later verouderd.
- *
- * Nooit een uitzondering: een module die valt over ontbrekende gegevens neemt de
- * hele pagina mee. Wat er niet is, is een leeg object.
- */
-export function pageData() {
-  try {
-    var el = document.querySelector('script[type="application/json"][data-page-data]');
-    return el ? (JSON.parse(el.textContent) || {}) : {};
-  } catch (e) {
-    return {};
-  }
-}
-
-/**
- * Een enkele waarde, met een terugval. Scheelt in elke module hetzelfde
- * gedoe met ontbrekende sleutels.
- */
-export function pageValue(key, fallback) {
-  var d = pageData();
-  return (d && Object.prototype.hasOwnProperty.call(d, key)) ? d[key] : fallback;
-}
-
-/**
- * Listeners op document en window overleven een paginawissel; de pagina zelf
- * niet. Een module die per navigatie opnieuw start (export init) registreert
- * zulke listeners hier, en veegt bij elke start de vorige lichting weg --
- * anders stapelen ze, elk met een closure naar elementen die al weg zijn.
- *
- * Listeners op ELEMENTEN hoeven hier niet doorheen: die sterven met hun
- * element bij de swap. Alleen wat het document overleeft, moet geveegd.
- */
-export function makeSweeper() {
-  let list = [];
-  return {
-    on(target, ev, fn, opts) { target.addEventListener(ev, fn, opts); list.push([target, ev, fn, opts]); },
-    sweep() { for (const [t, ev, fn, opts] of list) t.removeEventListener(ev, fn, opts); list = []; },
-  };
-}
-
-/**
- * Een tekst veilig in HTML zetten.
- *
- * De modules bouwen op sommige plekken HTML met stringplakwerk. Vroeger ging een
- * vertaling daar door de escapende EJS-tag heen; nu niet meer. Zonder deze functie
- * breekt een apostrof in een vertaling het attribuut af waar hij in staat -- het
- * soort fout dat pas in een andere taal opvalt.
- */
-export function esc(s) {
-  return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
-    return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
-  });
-}
-
-/**
- * De WebAuthn-bibliotheek erbij halen, vanuit JavaScript (shaer-0i6).
- *
- * Hij stond als `<script src>` in paid-gate.ejs en paid-passkey.ejs, en dat
- * werkt alleen bij een volledige laadbeurt. Kom je op zo'n pagina via een link
- * BINNEN de site, dan is het een htmx-fragment met de nonce van een ANDER
- * verzoek, en weigert de CSP hem. Het gevolg was stil: de module laadde wel,
- * vond `window.SimpleWebAuthnBrowser` niet, en de knop deed niets -- op het
- * passkey- en betaalpad.
- *
- * Een dynamische import vanuit deze module mag wel: de shell startte hem met
- * een geldige nonce, en `strict-dynamic` bestaat er juist voor dat vertrouwde
- * scripts verder mogen laden. Het bestand is een UMD-bundel; zonder CommonJS
- * of AMD zet die zichzelf op globalThis, dus na afloop staat de global er.
- *
- * Faalt het laden, dan geeft dit undefined terug en niet een fout: beide
- * aanroepers hebben al een nette terugval voor "geen WebAuthn hier".
- */
-/**
- * De cache-buster voor alles in /assets/vendor (shaer-724).
- *
- * Die map valt onder dezelfde max-age van een jaar als de rest van /assets, en
- * een vendorbestand heeft geen versie in zijn naam. Zonder query houdt een
- * browser die hem ooit zag hem dus een jaar vast, ook na een update van de
- * bibliotheek.
- *
- * EEN nummer voor de hele map, hier, en niet drie losse per module: read.js had
- * er al een en lib.js en post-edit.js hadden er geen, en dan is "ophogen als de
- * bibliotheek wijzigt" een opdracht die je op drie plekken moet onthouden. Dat
- * kost read.js wel een import van dit bestand op de leespagina; die is klein en
- * staat na de eerste pagina in de cache.
- */
-export const VENDOR_V = 1;
-
-export async function loadWebAuthn() {
-  if (window.SimpleWebAuthnBrowser) return window.SimpleWebAuthnBrowser;
-  try { await import(`/assets/vendor/simplewebauthn-browser.umd.min.js?v=${VENDOR_V}`); }
-  catch (e) { console.warn('[paid] WebAuthn-bibliotheek laadt niet:', e && e.message); }
-  return window.SimpleWebAuthnBrowser;
-}
Index: src/assets/js/mod/messages.js
===================================================================
--- src/assets/js/mod/messages.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,180 +1,0 @@
-// Berichten: filteren, zoeken, en gesprekken in- en uitklappen (shaer-bqr).
-//
-// Dit stond inline in pages/messages.ejs. Dat werkte alleen na een volledige
-// laadbeurt: kwam je hier via een link BINNEN de site, dan arriveerde het script
-// via htmx met een nonce die het document niet kent, en weigerde de CSP het
-// (shaer-0i6). Chips, zoeken en het inklappen deden dan niets, en de reply-editor
-// laadde niet -- dat was Barts melding.
-//
-// TWEE DINGEN VERANDERD bij de verhuizing, en ze zijn allebei nodig omdat een
-// module ANDERS leeft dan een inline script:
-//
-//   NIETS VASTHOUDEN   het inline script pakte .msg-list, #msg-q en .msg-nomatch
-//                      een keer bij het inladen. Een module wordt per document
-//                      maar EEN keer geimporteerd, dus als je Berichten verlaat
-//                      en terugkomt wijzen die naar elementen die er niet meer
-//                      zijn. Nu wordt er per keer opgezocht.
-//   OPNIEUW INDEXEREN  bij binnenkomst op deze pagina, niet alleen bij "meer
-//                      laden". Anders is de lijst na een navigatie niet
-//                      geindexeerd en filtert het zoeken op niets.
-//
-// Alles gedelegeerd op document, met een slot: de module kan een tweede keer
-// geladen worden en mag dan geen tweede stel luisteraars neerzetten.
-
-(function () {
-  'use strict';
-  if (window.__msgWired) return;
-  window.__msgWired = true;
-
-  var kind = 'all';
-  var items = [];
-
-  // Per keer opzoeken. Zie de kop: vasthouden overleeft een navigatie niet.
-  function list() { return document.querySelector('.msg-list'); }
-  function noMatch() { return document.querySelector('.msg-nomatch'); }
-  function query() { return document.getElementById('msg-q'); }
-
-  function indexItem(li) {
-    // Een draad indexeren op zijn eerste bubbel zou de rest onvindbaar maken,
-    // dus daar nemen we de hele tekst; losse regels blijven gericht geindexeerd.
-    if (li.classList.contains('msg-thread')) {
-      li._search = ((li.getAttribute('data-who') || '') + ' ' + li.textContent).toLowerCase();
-      return;
-    }
-    var body = li.querySelector('.msg-content');
-    var post = li.querySelector('.msg-post');
-    var poll = li.querySelector('.msg-poll');
-    li._search = ((li.getAttribute('data-who') || '') + ' ' +
-      (body ? body.textContent : '') + ' ' + (post ? post.textContent : '') + ' ' +
-      (poll ? poll.textContent : '')).toLowerCase();
-  }
-
-  function reindex() {
-    var l = list();
-    items = l ? Array.prototype.slice.call(l.querySelectorAll(':scope > .msg-item')) : [];
-    items.forEach(function (li) { if (!li._search) indexItem(li); });
-  }
-
-  function apply() {
-    if (!list()) return;
-    var qEl = query();
-    var term = (qEl && qEl.value || '').trim().toLowerCase();
-    var shown = 0;
-    items.forEach(function (li) {
-      var ok = (kind === 'all' || li.getAttribute('data-kind') === kind) &&
-        (!term || li._search.indexOf(term) !== -1);
-      li.style.display = ok ? '' : 'none';
-      // Een treffer die in een dichtgeklapt gesprek zit, laat anders alleen de
-      // naam zien: je zoekt iets, het staat er, en je ziet het niet. Zolang er
-      // gezocht wordt gaat zo'n gesprek open; daarna keert hij terug naar de
-      // stand die de lezer zelf koos.
-      if (li.classList.contains('msg-thread')) {
-        li.classList.toggle('is-search-open', !!term && ok);
-      }
-      if (ok) shown++;
-    });
-    var nm = noMatch();
-    if (nm) nm.hidden = shown !== 0;
-  }
-
-  // In- en uitklappen door op de tegenpartij te tikken. De stand leeft alleen in
-  // deze pagina: standaard uitgeklapt, en na een herlading weer. Dat is bewust --
-  // een gesprek dat je gisteren dichtklapte stilhouden is niet hetzelfde als het
-  // opruimen van je scherm van nu.
-  document.addEventListener('click', function (e) {
-    var who = e.target.closest && e.target.closest('.msg-thread-who'); if (!who) return;
-    var li = who.closest('.msg-thread'); if (!li) return;
-    var collapsed = li.classList.toggle('is-collapsed');
-    who.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
-  });
-
-  document.addEventListener('click', function (e) {
-    var chip = e.target.closest && e.target.closest('.msg-chip'); if (!chip) return;
-    kind = chip.getAttribute('data-show');
-    document.querySelectorAll('.msg-chip').forEach(function (c) { c.classList.toggle('is-on', c === chip); });
-    apply();
-  });
-
-  // Gedelegeerd in plaats van op het invoerveld zelf: dat veld wordt bij een
-  // navigatie vervangen.
-  document.addEventListener('input', function (e) {
-    if (e.target && e.target.id === 'msg-q') apply();
-  });
-
-  document.body.addEventListener('htmx:afterSettle', function (e) {
-    // "Meer laden" voegt rijen toe aan de bestaande lijst; een navigatie brengt
-    // een hele nieuwe lijst. In beide gevallen opnieuw indexeren en de actieve
-    // chip toepassen.
-    if (e.target && (e.target.id === 'msg-list' || e.target.querySelector && e.target.querySelector('.msg-list'))) {
-      reindex();
-      apply();
-    }
-  });
-
-  // De bookmarklet-knop: bouwt zijn href pas in de browser, want hij heeft de
-  // origin nodig.
-  function wireBookmarklet() {
-    var a = document.getElementById('fedi-bm-btn');
-    if (!a || a.__wired) return;
-    a.__wired = true;
-    a.setAttribute('href', "javascript:void(window.open('" + location.origin + "/authorize_interaction?uri='+encodeURIComponent(window.location.href)))");
-    a.addEventListener('click', function (e) {
-      e.preventDefault();
-      a.classList.add('nudge');
-      setTimeout(function () { a.classList.remove('nudge'); }, 600);
-    });
-  }
-
-  // Zwaaien ter plekke: een seintje is geen paginawissel.
-  //
-  // Het formulier blijft een echt formulier -- zonder JS post het en leidt de
-  // route om, precies als vroeger. Hier onderscheppen we het en sturen het met
-  // fetch, zoals like/boost in de Krant het al deden.
-  //
-  // LET OP DE VERZENDKNOP. Deze formulieren hebben er MEERDERE ("Wat leuk!",
-  // "Hoe gaat het?", "Terug"), elk met een eigen name="text". FormData(f) neemt
-  // de aangeklikte knop NIET mee -- dat doet de browser alleen bij een echte
-  // verzending. Zonder submitter vertrekt er dus een zwaai zonder tekst, en die
-  // weigert de route. Vandaar submitter, met een terugval op de eerste knop.
-  document.addEventListener('submit', function (e) {
-    var f = e.target.closest && e.target.closest('.msg-wave, .msg-quickreply');
-    if (!f || f.dataset.sending) return;
-    e.preventDefault();
-    var knoppen = [].slice.call(f.querySelectorAll('button'));
-    var knop = e.submitter || knoppen[0];
-    if (!knop) return;
-    f.dataset.sending = '1';
-    knoppen.forEach(function (b) { b.disabled = true; });
-    var body = new URLSearchParams();
-    new FormData(f).forEach(function (v, k) { body.append(k, v); });
-    if (knop.name) body.set(knop.name, knop.value);
-    fetch(f.action, {
-      method: 'POST', body: body,
-      headers: { 'X-Requested-With': 'fetch' },
-      credentials: 'same-origin',
-    })
-      .then(function (r) { return r.ok ? r.json() : null; })
-      .then(function (j) {
-        if (!j || !j.ok) throw new Error('geweigerd');
-        // Gelukt: de knoppen maken plaats voor de bevestiging. Zwaaien is
-        // eenmalig -- anders dan een like valt het niet terug te draaien, dus
-        // een aan/uit-knop zou liegen over wat er kan.
-        var klaar = document.createElement('span');
-        klaar.className = 'msg-wave-done';
-        klaar.textContent = '👋 ' + (f.dataset.sent || '');
-        f.replaceChildren(klaar);
-      })
-      .catch(function () {
-        // Mislukt: alles terug zoals het was, zodat een tweede poging kan.
-        delete f.dataset.sending;
-        knoppen.forEach(function (b) { b.disabled = false; });
-        f.classList.add('is-failed');
-        setTimeout(function () { f.classList.remove('is-failed'); }, 600);
-      });
-  });
-
-  function start() { reindex(); apply(); wireBookmarklet(); }
-  start();
-  // Kom je hier via een link binnen de site, dan is de lijst er pas na de swap.
-  document.body.addEventListener('pcmsNav', start);
-})();
Index: src/assets/js/mod/news.js
===================================================================
--- src/assets/js/mod/news.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,148 +1,0 @@
-// De Krant -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
-// BINNEN de site binnenkomt (shaer-0i6). Wat de server meegeeft komt uit
-// pageData(); interpolatie kan niet in een statisch bestand.
-
-import { pageData } from './lib.js';
-
-// De twee gedelegeerde blokken dragen een window-vlag en zijn bij een tweede
-// init een no-op; de twee SCANNERS (gif-video's, lees-meer) moeten juist wel
-// elke render opnieuw over de verse elementen lopen (shaer-5s1). Element-
-// vlaggen (data-gif-wired, data-rm) houden dubbel bedraden tegen.
-export function init() { run(); }
-
-function run() {
-
-  (function () {
-    if (window.__tlPasteWired) return; window.__tlPasteWired = true;
-    function modal() { return document.getElementById('tl-paste'); }
-    function openM() {
-      var m = modal(); if (!m) return;
-      var inp = m.querySelector('.tl-paste-input');
-      m.classList.add('is-open');
-      if (inp) {
-        inp.value = ''; inp.focus();
-        if (navigator.clipboard && navigator.clipboard.readText) {
-          navigator.clipboard.readText().then(function (t) {
-            t = (t || '').trim();
-            if (/^https?:\/\//i.test(t) && !inp.value) { inp.value = t; inp.select(); }
-          }).catch(function () {});
-        }
-      }
-    }
-    function closeM() { var m = modal(); if (m) m.classList.remove('is-open'); }
-    function go() {
-      var m = modal(); if (!m) return;
-      var inp = m.querySelector('.tl-paste-input'), v = (inp ? inp.value : '').trim();
-      if (!/^https?:\/\//i.test(v)) { if (inp) inp.focus(); return; }
-      location.href = '/authorize_interaction?uri=' + encodeURIComponent(v);
-    }
-    document.addEventListener('click', function (e) {
-      if (e.target.closest && e.target.closest('#tl-paste-btn')) { e.preventDefault(); openM(); return; }
-      if (e.target.closest && e.target.closest('.tl-paste-go')) { e.preventDefault(); go(); return; }
-      if (e.target.closest && (e.target.closest('.tl-paste-cancel') || e.target.closest('.tl-paste-x'))) { e.preventDefault(); closeM(); return; }
-      var op = document.querySelector('.tl-paste.is-open'); if (op && e.target === op) closeM();
-    });
-    document.addEventListener('keydown', function (e) {
-      var m = document.querySelector('.tl-paste.is-open'); if (!m) return;
-      if (e.key === 'Escape') closeM();
-      else if (e.key === 'Enter' && e.target.closest && e.target.closest('.tl-paste')) { e.preventDefault(); go(); }
-    });
-  })();
-  
-
-// ── volgend blok ──
-
-(function(){
-  if (window.__fediRemoteWired) return; window.__fediRemoteWired = true;
-  var current = null, currentBtn = null;
-  function close(){ if (current) { current.remove(); current = null; currentBtn = null; } }
-  function go(raw, uri){ var d=(raw||'').trim().replace(/^@?[^@\s]*@/,'').replace(/^https?:\/\//i,'').replace(/\/.*$/,'').trim(); if(d){ try{ localStorage.setItem('pcmsFediServer', d); }catch(e){} location.href='https://'+d+'/authorize_interaction?uri='+encodeURIComponent(uri||''); } }
-  function place(f, b){ var r=b.getBoundingClientRect(); f.style.top=(r.bottom+window.scrollY+6)+'px'; f.style.left=Math.max(8, Math.min(r.left+window.scrollX, window.scrollX+window.innerWidth-340))+'px'; }
-  document.addEventListener('click', function(e){
-    if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; }
-    if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return;
-    var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn');
-    if (b) {
-      e.preventDefault();
-      if (currentBtn === b) { close(); return; }
-      close();
-      var f = document.createElement('form'); f.className='fedi-remote-form'; f.dataset.uri = b.getAttribute('data-fedi-uri')||'';
-      f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false"><button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">&rarr;</button><button type="button" class="fedi-remote-cancel" aria-label="x">&times;</button>';
-      var _inp = f.querySelector('input'); _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social';
-      try{ var _sv = localStorage.getItem('pcmsFediServer'); if(_sv) _inp.value = _sv; }catch(e){}
-      document.body.appendChild(f); place(f, b); current=f; currentBtn=b; _inp.focus(); _inp.select();
-      return;
-    }
-    if (current) close();
-  });
-  document.addEventListener('submit', function(e){ var f=e.target.closest && e.target.closest('.fedi-remote-form'); if(!f) return; e.preventDefault(); go(f.querySelector('input').value, f.dataset.uri); });
-  document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
-  window.addEventListener('scroll', close, true);
-})();
-
-/* Short feed videos (gif/cover loops like an animated cover) autoplay + loop muted like a GIF;
-   longer real videos keep their controls. Decided on the actual duration once metadata loads. */
-wireGifVideos();
-
-/* Like/Boost toggle in place — POST via fetch, flip the button, stay on the page (no reload, no banner). */
-(function(){
-  if (window.__newsReactWired) return; window.__newsReactWired = true;
-  document.addEventListener('submit', function(e){
-    var f = e.target.closest && e.target.closest('.tl-react-form');
-    if (!f) return;
-    e.preventDefault();
-    var btn = f.querySelector('button'); if (!btn || btn.disabled) return;
-    btn.disabled = true;
-    var body = new URLSearchParams();
-    new FormData(f).forEach(function(v, k){ body.append(k, v); });
-    fetch(f.action, { method: 'POST', body: body, headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' })
-      .then(function(r){ return r.ok ? r.json() : null; })
-      .then(function(j){ if (j) btn.classList.toggle('is-on', !!j.on); })
-      .catch(function(){})
-      .then(function(){ btn.disabled = false; });
-  });
-})();
-
-// ── volgend blok ──
-
-// Collapse long post bodies to a max height with a "read more" toggle — only when the content
-// actually overflows. Runs on every /news render (full load + htmx swap); a per-element flag
-// prevents double-wiring.
-wireReadMore();
-}
-
-/* Short feed videos: zie de opmerking bij de aanroep in run(). */
-function wireGifVideos(){
-  document.querySelectorAll('.tl-media-video').forEach(function(v){
-    if (v.dataset.gifWired) return; v.dataset.gifWired = '1';
-    var decide = function(){
-      if (v.duration && v.duration <= 30) {
-        v.removeAttribute('controls'); v.loop = true; v.muted = true; v.play().catch(function(){});
-      }
-    };
-    if (v.readyState >= 1) decide(); else v.addEventListener('loadedmetadata', decide, { once: true });
-  });
-}
-
-function wireReadMore(){
-  var _d = pageData(); var RM = _d.readMore || 'Lees meer', SL = _d.showLess || 'Toon minder';
-  document.querySelectorAll('.tl-content:not(.nsfw-media):not([data-rm])').forEach(function(c){
-    c.setAttribute('data-rm', '1');
-    if (c.scrollHeight > 360) {
-      c.classList.add('tl-clamp');
-      var b = document.createElement('button');
-      b.type = 'button'; b.className = 'tl-readmore'; b.textContent = RM;
-      b.addEventListener('click', function(){
-        b.textContent = c.classList.toggle('tl-clamp') ? RM : SL;
-      });
-      c.insertAdjacentElement('afterend', b);
-    }
-  });
-}
-
-// Paginering: items die de feed later binnenhaalt zouden de scanners missen --
-// init() draait per NAVIGATIE, en een pagineer-swap is er geen. Een keer,
-// gedelegeerd; op andere pagina's vinden de scanners niets en doen ze niets.
-document.body.addEventListener('htmx:afterSettle', function () { wireGifVideos(); wireReadMore(); });
Index: src/assets/js/mod/paid-gate.js
===================================================================
--- src/assets/js/mod/paid-gate.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,72 +1,0 @@
-// De betaalmuur -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
-// BINNEN de site binnenkomt (shaer-0i6). Servergegevens komen uit pageData();
-// interpolatie kan niet in een statisch bestand.
-
-import { pageData, loadWebAuthn } from './lib.js';
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-//
-// EERST de bibliotheek, dan pas bedraden (shaer-0i6): die stond als
-// `<script src>` in de pagina en overleefde een htmx-navigatie niet. Wacht
-// run() daar niet op, dan valt hij terug op "geen WebAuthn" en verbergt hij
-// de ontgrendelknop op een pagina waar ontgrendelen gewoon kan.
-export async function init() { await loadWebAuthn(); run(); }
-
-function run() {
-(function () {
-  var _d = pageData();
-  var base = _d.base || "";
-  var slug = _d.slug || "";
-  var hasPatron = !!_d.hasPatron;
-  var I = _d.i18n || {};
-  var btn = document.getElementById('pg-unlock');
-  var status = document.getElementById('pg-status');
-  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
-  function toLink() { location.href = base + '/paid/link?post=' + encodeURIComponent(slug); }
-
-  // No WebAuthn here: an assertion is impossible. With a Patreon page there is
-  // already a "Word supporter" button, so hide the (dead) unlock button rather
-  // than turn it into a second "Word supporter". Without one, this IS the button.
-  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
-    if (hasPatron) { btn.style.display = 'none'; }
-    else { btn.textContent = I.join; btn.addEventListener('click', toLink); }
-    return;
-  }
-
-  btn.addEventListener('click', function () {
-    btn.disabled = true;
-    say(I.confirm);
-    fetch(base + '/paid/challenge?post=' + encodeURIComponent(slug))
-      .then(function (r) { if (!r.ok) throw { link: true }; return r.json(); })
-      .then(function (data) {
-        return window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: data.options })
-          .then(function (response) {
-            return fetch(base + '/paid/unlock', {
-              method: 'POST', headers: { 'Content-Type': 'application/json' },
-              body: JSON.stringify({ response: response, blob: data.blob }),
-            });
-          });
-      })
-      .then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
-      .then(function (res) {
-        if (res.j && res.j.ok && res.j.redirect) {
-          // Reload the real post page via the one-shot unlock capability, so it
-          // renders through its normal template (layout, styles, audio).
-          location.href = res.j.redirect;
-        } else if (res.status === 403) {
-          toLink();   // no valid passkey yet (or lapsed tier): link via Patreon
-        } else {
-          btn.disabled = false; say(I.failed, true);
-        }
-      })
-      .catch(function (e) {
-        if (e && e.link) { toLink(); return; }
-        if (e && e.name === 'NotAllowedError') { toLink(); return; }   // cancelled / no passkey -> link
-        btn.disabled = false; say(I.error, true);
-      });
-  });
-})();
-}
Index: src/assets/js/mod/paid-passkey.js
===================================================================
--- src/assets/js/mod/paid-passkey.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,52 +1,0 @@
-// De passkey-ontgrendeling -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script wordt door de CSP geweigerd zodra deze pagina via een link
-// BINNEN de site binnenkomt (shaer-0i6). Servergegevens komen uit pageData();
-// interpolatie kan niet in een statisch bestand.
-
-import { pageData, loadWebAuthn } from './lib.js';
-
-// Element-bedrading leeft zo lang als de pagina; de bootstrap roept init()
-// aan bij elke paginawissel waarop deze module actief is (shaer-5s1).
-//
-// EERST de bibliotheek, dan pas bedraden (shaer-0i6): die stond als
-// `<script src>` in de pagina en overleefde een htmx-navigatie niet. Wacht
-// run() daar niet op, dan ziet hij de global nog niet staan en zet hij de
-// knop uit terwijl er niets aan de hand is.
-export async function init() { await loadWebAuthn(); run(); }
-
-function run() {
-(function () {
-  var _d = pageData();
-  var options = _d.options || {};
-  var blob = _d.blob || "";
-  var I = _d.i18n || {};
-  var postUrl = _d.postUrl || "";
-  var btn = document.getElementById('pk-go');
-  var status = document.getElementById('pk-status');
-  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
-
-  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
-    btn.disabled = true;
-    say(I.unsupported, true);
-    return;
-  }
-  btn.addEventListener('click', function () {
-    btn.disabled = true;
-    say(I.follow);
-    window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: options })
-      .then(function (response) {
-        return fetch('/paid/register', {
-          method: 'POST', headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ response: response, blob: blob }),
-        });
-      })
-      .then(function (r) { return r.json(); })
-      .then(function (j) {
-        if (j && j.ok) { say(I.done); setTimeout(function () { location.href = postUrl; }, 900); }
-        else { btn.disabled = false; say(I.failed.replace('{err}', (j && j.error) || '?'), true); }
-      })
-      .catch(function (e) { btn.disabled = false; say(e && e.name === 'NotAllowedError' ? I.cancelled : I.error, true); });
-  });
-})();
-}
Index: src/assets/js/mod/playlist-editor.js
===================================================================
--- src/assets/js/mod/playlist-editor.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,467 +1,0 @@
-// De afspeellijst-editor -- verplaatst uit inline script, shaer-bqr.
-//
-// Servergegevens komen uit pageData(); interpolatie kan niet in een statisch
-// bestand. Inline script wordt bovendien geweigerd zodra deze pagina via een
-// link BINNEN de site binnenkomt (shaer-0i6).
-
-import { pageData } from './lib.js';
-
-/**
- * De teksten van deze modal, uit het gegevensblok van partials/playlist-editor.ejs.
- *
- * EIGEN BLOK en niet pageData(): die pakt met querySelector er EEN, en deze
- * modal hangt onder pagina's die er zelf al een hebben. Een tweede blok zou
- * daar genegeerd worden.
- *
- * De terugval is bewust de sleutelnaam en niet de Nederlandse tekst: ontbreekt
- * er iets, dan zie je DAT er iets ontbreekt in plaats van een pagina die er
- * half vertaald uitziet en waarvan niemand merkt welke helft.
- */
-let _T = null;
-function T(sleutel) {
-  if (_T === null) {
-    try {
-      const el = document.querySelector('script[type="application/json"][data-playlist-editor-i18n]');
-      _T = el ? (JSON.parse(el.textContent) || {}) : {};
-    } catch (e) { _T = {}; }
-  }
-  return _T[sleutel] != null ? _T[sleutel] : sleutel;
-}
-
-(function() {
-  // Idempotency: if window.openPlaylistEditor already defined (multiple
-  // partial includes on a single page), skip re-binding.
-  if (typeof window.openPlaylistEditor === 'function') return;
-
-  const CSRF = pageData().csrf || '';
-
-  function esc(s) {
-    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
-      '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
-    }[c]));
-  }
-  function fmtDur(sec) {
-    if (!sec) return '';
-    const m = Math.floor(sec / 60), s = sec % 60;
-    return `${m}:${String(s).padStart(2, '0')}`;
-  }
-
-  async function api(method, url, body) {
-    const opts = {
-      method, credentials: 'same-origin',
-      headers: { 'X-CSRF-Token': CSRF },
-    };
-    if (body !== undefined) {
-      opts.headers['Content-Type'] = 'application/json';
-      opts.body = JSON.stringify(body);
-    }
-    const r = await fetch(url, opts);
-    return r.json();
-  }
-
-  /**
-   * Public entry point: open the editor.
-   *  opts: { mode: 'create'|'edit', id?, onSaved? }
-   *  onSaved is called with { id, playlist } after a successful save.
-   */
-  window.openPlaylistEditor = async function(opts) {
-    opts = opts || {};
-    const mode = opts.mode === 'edit' ? 'edit' : 'create';
-    const isEdit = mode === 'edit';
-
-    // Load all audio tracks for the picker
-    let tracks = [];
-    try {
-      const j = await api('GET', '/admin/playlists/api/tracks');
-      if (Array.isArray(j.tracks)) tracks = j.tracks;
-    } catch (e) {
-      alert(T('e_tracks'));
-      return;
-    }
-    if (!tracks.length) {
-      alert(T('e_geen_tracks'));
-      return;
-    }
-
-    // Existing playlist data when editing
-    let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', release_date: '', mb_release_id: '', track_ids: [] };
-    if (isEdit && opts.id) {
-      try {
-        const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
-        if (j.ok) initial = { ...initial, ...j.playlist };
-      } catch (e) {}
-    }
-
-    // ── DOM ────────────────────────────────────────────────────────
-    const backdrop = document.createElement('div');
-    backdrop.className = 'pl-modal-backdrop';
-    backdrop.innerHTML = `
-      <div class="pl-modal" role="dialog" aria-label="${T('dialoog')}">
-        <div class="pl-modal-header">
-          <h3>${isEdit ? '✎ ' + T('t_edit') : '+ ' + T('t_new')}</h3>
-          <button type="button" class="pl-modal-close" aria-label="${T('sluiten')}">×</button>
-        </div>
-        <div class="pl-modal-body">
-          <div class="pl-editor-cols">
-            <div class="pl-editor-left">
-              <div class="pl-meta-grid">
-                <label class="pl-field pl-field-full">
-                  <span>${T('titel')}</span>
-                  <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
-                </label>
-                <label class="pl-field">
-                  <span>${T('artiest')}</span>
-                  <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
-                </label>
-                <label class="pl-field">
-                  <span>${T('jaar')}</span>
-                  <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
-                </label>
-                <label class="pl-field">
-                  <span>${T('type')}</span>
-                  <select id="pli-kind">
-                    <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 ${T('k_album')}</option>
-                    <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 ${T('k_playlist')}</option>
-                    <option value="mixtape"  ${initial.kind === 'mixtape' ? 'selected' : ''}>📼 ${T('k_mixtape')}</option>
-                  </select>
-                </label>
-                <label class="pl-field pl-uitgave">
-                  <span>${T('uitgave')}</span>
-                  <input type="date" id="pli-release-date" value="${esc(initial.release_date || '')}">
-                </label>
-                <label class="pl-field pl-uitgave">
-                  <span>${T('mb_release')}</span>
-                  <input type="text" id="pli-mb-release" maxlength="36" spellcheck="false"
-                         placeholder="00000000-0000-0000-0000-000000000000"
-                         value="${esc(initial.mb_release_id || '')}">
-                </label>
-                <div class="pl-field pl-field-full">
-                  <span>${T('cover')}</span>
-                  <div class="pl-cover-row">
-                    <span class="pl-cover-thumb" id="pli-cover-thumb">
-                      ${initial.cover
-                        ? `<img src="${esc(initial.cover)}" alt="">`
-                        : `<span class="pl-cover-empty">🎨</span>`}
-                    </span>
-                    <input type="text" id="pli-cover" placeholder="${T('cover_url')}" value="${esc(initial.cover)}" style="flex:1">
-                  </div>
-                  <div class="pl-cover-upload-row">
-                    <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
-                    <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 ${T('cover_kies')}</button>
-                    <span class="pl-cover-status" id="pli-cover-status"></span>
-                  </div>
-                </div>
-              </div>
-              <div class="pl-section-title">
-                ${T('tracks_in')} <span class="pl-track-count" id="pli-count">0</span>
-                <small>${T('sleep_hint')}</small>
-              </div>
-              <div id="pli-selected" class="pl-selected-list"></div>
-            </div>
-            <div class="pl-editor-right">
-              <div class="pl-section-title">${T('beschikbaar')}</div>
-              <input type="search" id="pli-search" placeholder="${T('zoek')}" class="pl-search-input">
-              <div id="pli-available" class="pl-available-list"></div>
-            </div>
-          </div>
-        </div>
-        <div class="pl-modal-footer">
-          <button type="button" class="btn" id="pli-cancel">${T('annuleren')}</button>
-          <button type="button" class="btn btn-primary" id="pli-save" disabled>
-            ${isEdit ? T('opslaan') : T('aanmaken')}
-          </button>
-        </div>
-      </div>
-    `;
-    document.body.appendChild(backdrop);
-
-    const $ = sel => backdrop.querySelector(sel);
-    const close = () => backdrop.remove();
-    backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
-    $('.pl-modal-close').addEventListener('click', close);
-    $('#pli-cancel').addEventListener('click', close);
-
-    // Index tracks by id for fast lookup
-    const trackById = new Map(tracks.map(t => [t.id, t]));
-    let selected = (initial.track_ids || []).filter(id => trackById.has(id));
-
-    const titleEl = $('#pli-title');
-    const artistEl = $('#pli-artist');
-    const yearEl = $('#pli-year');
-    const kindEl = $('#pli-kind');
-    const releaseEl = $('#pli-release-date');
-    const mbReleaseEl = $('#pli-mb-release');
-    const coverEl = $('#pli-cover');
-
-    // Uitgavedatum en release-id horen bij een ALBUM, niet bij een
-    // afspeellijst -- dat is wat de keuze album/playlist betekent. Ze
-    // verdwijnen dus als je omschakelt, en de server maakt ze dan ook leeg;
-    // dit scherm is de uitleg, niet de bewaking.
-    // ALLEEN een album, en niet "alles behalve playlist". Dat stond er eerst, en
-    // met een derde soort erbij zou een mixtape ineens uitgavevelden tonen die
-    // de server toch weggooit -- een scherm dat iets anders belooft dan het doet.
-    const toonUitgave = () => {
-      const album = kindEl.value === 'album';
-      for (const el of backdrop.querySelectorAll('.pl-uitgave')) el.hidden = !album;
-    };
-    kindEl.addEventListener('change', toonUitgave);
-    toonUitgave();
-    const coverThumb = $('#pli-cover-thumb');
-    const saveBtn = $('#pli-save');
-    const selectedEl = $('#pli-selected');
-    const availEl = $('#pli-available');
-    const searchEl = $('#pli-search');
-    const countEl = $('#pli-count');
-
-    coverEl.addEventListener('input', () => {
-      const u = coverEl.value.trim();
-      coverThumb.innerHTML = u
-        ? `<img src="${esc(u)}" alt="" data-fallback>`
-        : `<span class="pl-cover-empty">🎨</span>`;
-    });
-
-    // ── Cover file upload (werkt in create ÉN edit) ───────────
-    // We uploaden naar het generieke image-endpoint (/posts/upload-image,
-    // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
-    // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het
-    // cover-veld en wordt bij het opslaan met de playlist meegestuurd.
-    const coverFileInput = $('#pli-cover-file');
-    const coverPickBtn   = $('#pli-cover-pick');
-    const coverStatus    = $('#pli-cover-status');
-    if (coverFileInput && coverPickBtn) {
-      coverPickBtn.addEventListener('click', () => coverFileInput.click());
-      coverFileInput.addEventListener('change', async (e) => {
-        const file = e.target.files && e.target.files[0];
-        coverFileInput.value = '';
-        if (!file) return;
-        if (!/^image\//.test(file.type)) {
-          coverStatus.textContent = T('e_alleen_afb');
-          coverStatus.className = 'pl-cover-status is-error';
-          return;
-        }
-        coverStatus.textContent = T('bezig');
-        coverStatus.className = 'pl-cover-status';
-        const fd = new FormData();
-        fd.append('image', file);
-        try {
-          const r = await fetch('/posts/upload-image',
-            { method: 'POST', body: fd, credentials: 'same-origin' }
-          );
-          const j = await r.json();
-          if (!r.ok || !j.url) throw new Error(j.error || T('e_upload'));
-          const url = j.url || '';
-          coverEl.value = url;
-          coverThumb.innerHTML = url
-            ? `<img src="${esc(url)}" alt="">`
-            : `<span class="pl-cover-empty">🎨</span>`;
-          coverStatus.textContent = '✓ Geüpload';
-          coverStatus.className = 'pl-cover-status is-ok';
-        } catch (err) {
-          coverStatus.textContent = T('e_mislukt') + err.message;
-          coverStatus.className = 'pl-cover-status is-error';
-        }
-      });
-    }
-
-    function updateSaveBtn() {
-      saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
-    }
-
-    function renderSelected() {
-      countEl.textContent = selected.length;
-      if (selected.length === 0) {
-        selectedEl.innerHTML = '<div class="pl-empty">' + T('leeg_sel') + '</div>';
-        updateSaveBtn();
-        return;
-      }
-      selectedEl.innerHTML = selected.map((id, i) => {
-        const t = trackById.get(id);
-        if (!t) return '';
-        const cover = t.cover
-          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
-          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
-        return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
-          <span class="pl-row-handle" aria-label="${T('versleep')}">⠿</span>
-          <span class="pl-row-num">${i + 1}</span>
-          ${cover}
-          <span class="pl-row-info">
-            <span class="pl-row-title">${esc(t.title)}</span>
-            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
-          </span>
-          <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="${T('verwijder')}">×</button>
-        </div>`;
-      }).join('');
-
-      selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
-        b.addEventListener('click', () => {
-          selected = selected.filter(x => x !== b.dataset.id);
-          renderSelected();
-          renderAvailable();
-        });
-      });
-      bindDrag();
-      updateSaveBtn();
-    }
-
-    function renderAvailable() {
-      const q = searchEl.value.trim().toLowerCase();
-      const matches = tracks.filter(t => {
-        if (!q) return true;
-        return (t.title || '').toLowerCase().includes(q)
-            || (t.artist || '').toLowerCase().includes(q);
-      });
-      if (matches.length === 0) {
-        availEl.innerHTML = '<div class="pl-empty">' + T('geen_res') + '</div>';
-        return;
-      }
-      availEl.innerHTML = matches.map(t => {
-        const isAdded = selected.includes(t.id);
-        const cls = ['pl-avail-row'];
-        if (isAdded) cls.push('is-added');
-        if (!t.playable) cls.push('is-unplayable');
-        const cover = t.cover
-          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
-          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
-        return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : `title="${T('geen_audio')}"`}>
-          ${cover}
-          <span class="pl-row-info">
-            <span class="pl-row-title">${esc(t.title)}</span>
-            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
-          </span>
-          <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
-        </div>`;
-      }).join('');
-
-      availEl.querySelectorAll('.pl-avail-row').forEach(row => {
-        if (row.classList.contains('is-unplayable')) return;
-        row.addEventListener('click', () => {
-          const id = row.dataset.id;
-          if (selected.includes(id)) selected = selected.filter(x => x !== id);
-          else selected.push(id);
-          renderSelected();
-          renderAvailable();
-        });
-      });
-    }
-
-    // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
-    function bindDrag() {
-      selectedEl.querySelectorAll('.pl-row').forEach(row => {
-        const handle = row.querySelector('.pl-row-handle');
-        if (!handle) return;
-        let dragging = false, originalIdx = -1;
-
-        handle.addEventListener('pointerdown', e => {
-          e.preventDefault();
-          handle.setPointerCapture(e.pointerId);
-          dragging = true;
-          originalIdx = parseInt(row.dataset.pos, 10);
-          row.classList.add('is-dragging');
-        });
-        handle.addEventListener('pointermove', e => {
-          if (!dragging) return;
-          e.preventDefault();
-          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
-          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
-          let targetIdx = -1, above = false;
-          for (let i = 0; i < rows.length; i++) {
-            const r = rows[i]; if (r === row) continue;
-            const rect = r.getBoundingClientRect();
-            const mid = rect.top + rect.height / 2;
-            if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
-            if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
-              targetIdx = i; above = e.clientY < mid; break;
-            }
-          }
-          if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
-        });
-        const finish = e => {
-          if (!dragging) return;
-          dragging = false;
-          try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
-          row.classList.remove('is-dragging');
-          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
-          let targetIdx = -1, above = false;
-          for (let i = 0; i < rows.length; i++) {
-            if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
-            if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
-          }
-          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
-          if (targetIdx === -1 || targetIdx === originalIdx) return;
-          const moved = selected[originalIdx];
-          selected.splice(originalIdx, 1);
-          let newIdx = targetIdx;
-          if (originalIdx < targetIdx) newIdx--;
-          if (!above) newIdx++;
-          newIdx = Math.max(0, Math.min(selected.length, newIdx));
-          selected.splice(newIdx, 0, moved);
-          renderSelected();
-        };
-        handle.addEventListener('pointerup', finish);
-        handle.addEventListener('pointercancel', () => {
-          dragging = false;
-          row.classList.remove('is-dragging');
-          selectedEl.querySelectorAll('.drop-above, .drop-below')
-            .forEach(r => r.classList.remove('drop-above', 'drop-below'));
-        });
-      });
-    }
-
-    titleEl.addEventListener('input', updateSaveBtn);
-    searchEl.addEventListener('input', renderAvailable);
-    renderSelected();
-    renderAvailable();
-
-    saveBtn.addEventListener('click', async () => {
-      if (saveBtn.disabled) return;
-      const orig = saveBtn.textContent;
-      saveBtn.disabled = true;
-      saveBtn.textContent = T('bezig_opslaan');
-
-      const payload = {
-        title:  titleEl.value.trim(),
-        artist: artistEl.value.trim(),
-        year:   parseInt(yearEl.value, 10) || 0,
-        cover:  coverEl.value.trim(),
-        // De waarde zelf. Deze regel was `=== 'playlist' ? 'playlist' : 'album'`,
-        // en dat is precies de vorm die een nieuwe soort niet afwijst maar
-        // opslokt: een mixtape werd hier stil een album. De server heeft het
-        // laatste woord via normKind().
-        kind:   kindEl.value,
-        release_date:  releaseEl.value.trim(),
-        mb_release_id: mbReleaseEl.value.trim(),
-        tracks: selected.slice(),
-      };
-
-      try {
-        const url = isEdit
-          ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
-          : '/admin/playlists/api';
-        const j = await api('POST', url, payload);
-        if (!j.ok) {
-          alert(T('e_opslaan') + (j.error || 'onbekend'));
-          saveBtn.disabled = false;
-          saveBtn.textContent = orig;
-          return;
-        }
-        const savedId = isEdit ? initial.id : j.id;
-        close();
-        if (typeof opts.onSaved === 'function') {
-          opts.onSaved({ id: savedId, playlist: payload });
-        }
-      } catch (err) {
-        alert(T('e_opslaan') + err.message);
-        saveBtn.disabled = false;
-        saveBtn.textContent = orig;
-      }
-    });
-
-    // ESC to close
-    document.addEventListener('keydown', function onEsc(e) {
-      if (e.key === 'Escape' && document.body.contains(backdrop)) {
-        close();
-        document.removeEventListener('keydown', onEsc);
-      }
-    });
-  };
-})();
Index: src/assets/js/mod/post-edit.js
===================================================================
--- src/assets/js/mod/post-edit.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,1215 +1,0 @@
-// De posteditor -- verplaatst uit inline script, shaer-bqr.
-//
-// Zeven blokken, ruim 1000 regels. Alle servertekst komt uit pageData() en gaat
-// door esc(): de EJS-tag ontsnapte die vroeger ook, dus dit is gedragsgelijk en
-// het houdt een apostrof in een vertaling uit de HTML die hier geplakt wordt.
-
-import { pageData, esc, makeSweeper, VENDOR_V } from './lib.js';
-// Dezelfde regel die de server gebruikt -- zie shared/post-music-type.js voor
-// waarom hij daar staat en niet twee keer.
-import { afleidenUitInsluitingen, SOORTEN } from '../shared/post-music-type.js';
-
-// De oude inline scripts draaiden bij ELKE render; een module draait zijn
-// top-level een keer per sessie. Vandaar init(): de bootstrap roept hem aan
-// bij elke paginawissel waarop deze module actief is, en de veger haalt eerst
-// de document/window-listeners van de vorige pagina weg -- die overleven de
-// swap, met closures naar elementen die al verdwenen zijn (shaer-5s1).
-const doc = makeSweeper();
-let T = {};
-let _barObserver = null;
-
-export function init() {
-  doc.sweep();
-  if (_barObserver) { _barObserver.disconnect(); _barObserver = null; }
-  T = pageData();
-  run();
-}
-
-function run() {
-
-          (function () {
-            var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn');
-            if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); }
-          })();
-          
-
-// ── volgend blok ──
-
-        (function () {
-          var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw');
-          // Typing a warning text implies the post is sensitive → auto-tick NSFW.
-          if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true;
-            cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; });
-          }
-        })();
-        
-
-// ── volgend blok ──
-
-        (function () {
-          var box = document.getElementById('pe-poll-fields');
-          var tog = document.getElementById('pe-poll-toggle');
-          var opts = document.getElementById('pe-poll-opts');
-          var add = document.getElementById('pe-poll-add');
-          if (!box || !opts) return;
-          if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); }
-          var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || '';
-          function rows() { return opts.querySelectorAll('.pe-poll-row'); }
-          // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8.
-          function refresh() {
-            var n = rows().length;
-            opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; });
-            if (add) add.disabled = n >= 8;
-          }
-          function makeRow() {
-            var row = document.createElement('div'); row.className = 'pe-poll-row';
-            var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH;
-            var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '&times;';
-            row.appendChild(i); row.appendChild(d); return row;
-          }
-          if (add && !add.__wired) { add.__wired = true; add.addEventListener('click', function () { if (rows().length >= 8) return; opts.appendChild(makeRow()); refresh(); }); }
-          if (!opts.__wired) { opts.__wired = true; opts.addEventListener('click', function (e) { var d = e.target.closest('.pe-poll-del'); if (!d || rows().length <= 2) return; d.closest('.pe-poll-row').remove(); refresh(); }); }
-          refresh();
-        })();
-        
-
-// ── volgend blok ──
-
-            (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price');
-              if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })();
-          
-
-// ── volgend blok ──
-
-            (function () {
-              var cb = document.getElementById('pe-sched-toggle');
-              var box = document.getElementById('pe-sched-fields');
-              if (!cb || !box) return;
-              var inp = document.getElementById('pe-publish-at');
-              var SITE_TZ = '' + (T._timezone || '') + ''; // configured site timezone; empty = browser local
-              var pad = function (n) { return String(n).padStart(2, '0'); };
-              // Offset (ms) between a timezone and UTC at a given moment.
-              function tzOffset(date, tz) {
-                var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
-                var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; });
-                return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime();
-              }
-              // datetime-local "wall time" (in the site zone) → UTC Date.
-              function wallToUtc(wall) {
-                if (!SITE_TZ) return new Date(wall);
-                var guess = new Date(wall + ':00Z').getTime();
-                return new Date(guess - tzOffset(new Date(guess), SITE_TZ));
-              }
-              // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone.
-              function utcToWall(iso) {
-                var d = new Date(iso); if (isNaN(d)) return '';
-                if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
-                var f = new Intl.DateTimeFormat('en-CA', { timeZone: SITE_TZ, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
-                var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; });
-                return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
-              }
-              // Prefill: stored UTC → wall time in the site zone.
-              if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso);
-              // "Scheduled for" in human-readable time in the site zone.
-              var when = document.getElementById('pe-sched-when');
-              if (when && when.dataset.iso) {
-                var dw = new Date(when.dataset.iso);
-                if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined);
-              }
-              function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; }
-              cb.addEventListener('change', sync); sync();
-              // On save: wall time in the site zone → UTC-ISO via a hidden field.
-              var form = cb.closest('form');
-              if (form) {
-                form.addEventListener('submit', function () {
-                  if (inp) inp.removeAttribute('name');
-                  var old = form.querySelector('input[data-pa-utc]');
-                  if (old) old.remove();
-                  if (cb.checked && inp && inp.value) {
-                    var d2 = wallToUtc(inp.value);
-                    if (!isNaN(d2)) {
-                      var h = document.createElement('input');
-                      h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', '');
-                      h.value = d2.toISOString();
-                      form.appendChild(h);
-                    }
-                  }
-                });
-              }
-            })();
-          
-
-// ── volgend blok ──
-
-(function() {
-
-  // ── Cover upload ────────────────────────────────────────────────
-  const coverField   = document.getElementById('cover-upload-field');
-  const coverTrigger = document.getElementById('cover-upload-trigger');
-  const coverUrl     = document.getElementById('cover-url-field');
-  const coverVideo   = document.getElementById('cover-video-field');
-  const coverStatus  = document.getElementById('cover-upload-status');
-  const coverWrap    = document.getElementById('cover-preview-wrap');
-  const coverImg     = document.getElementById('cover-preview-img');
-
-  async function uploadImage(file) {
-    const fd = new FormData();
-    fd.append('image', file);
-    const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
-    if (!res.ok) {
-      const j = await res.json().catch(() => ({}));
-      throw new Error(j.error || ('Upload failed (' + res.status + ')'));
-    }
-    return await res.json();   // {url, size, mime}
-  }
-
-  // ── Image editor (rotate / crop / mirror) ──────────
-  // Lazy-load Cropper.js (locally vendored) on first use.
-  let _cropperReady = null;
-  function ensureCropper() {
-    if (window.Cropper) return Promise.resolve();
-    if (_cropperReady) return _cropperReady;
-    _cropperReady = new Promise((resolve, reject) => {
-      if (!document.querySelector('link[data-cropper-css]')) {
-        const l = document.createElement('link');
-        // Met versie, net als het script eronder (shaer-724): /assets wordt een
-        // jaar gecachet en deze twee bestanden dragen geen versie in hun naam.
-        l.rel = 'stylesheet'; l.href = `/assets/vendor/cropper.min.css?v=${VENDOR_V}`; l.setAttribute('data-cropper-css', '');
-        document.head.appendChild(l);
-      }
-      const s = document.createElement('script');
-      s.src = `/assets/vendor/cropper.min.js?v=${VENDOR_V}`;
-      s.onload = () => resolve();
-      s.onerror = () => reject(new Error('cropper load failed'));
-      document.head.appendChild(s);
-    });
-    return _cropperReady;
-  }
-
-  // True for an animated WebP (VP8X chunk with the animation flag set) — like a GIF it must skip
-  // the canvas editor, otherwise it'd be flattened to a single static frame.
-  async function isAnimatedWebpFile(file) {
-    if (!file || file.type !== 'image/webp') return false;
-    try {
-      const b = new Uint8Array(await file.slice(0, 40).arrayBuffer());
-      return b.length >= 21 && String.fromCharCode(b[12], b[13], b[14], b[15]) === 'VP8X' && (b[20] & 0x02) !== 0;
-    } catch (_) { return false; }
-  }
-
-  // Opens the editor for a chosen file; resolves with an edited File,
-  // or null if the user cancels. Animated images (GIF / animated WebP) are NOT sent through the
-  // canvas editor (they would become static) — those upload directly.
-  async function openImageEditor(file) {
-    if (!file || !file.type || !file.type.startsWith('image/')) return file;
-    if (file.type === 'image/gif') return file;               // preserve animation
-    if (await isAnimatedWebpFile(file)) return file;          // animated WebP → preserve animation
-    try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly
-
-    return new Promise((resolve) => {
-      const back = document.createElement('div');
-      back.className = 'imed-backdrop';
-      back.innerHTML =
-        '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="' + esc(T.title) + '">' +
-          '<div class="imed-stage"><img alt=""></div>' +
-          '<div class="imed-tools">' +
-            '<button type="button" data-act="rl" title="' + esc(T.rotate_left) + '">⟲</button>' +
-            '<button type="button" data-act="rr" title="' + esc(T.rotate_right) + '">⟳</button>' +
-            '<button type="button" data-act="fh" title="' + esc(T.flip_h) + '">⇆</button>' +
-            '<button type="button" data-act="fv" title="' + esc(T.flip_v) + '">⇅</button>' +
-            '<button type="button" data-act="zi" title="' + esc(T.zoom_in) + '">＋</button>' +
-            '<button type="button" data-act="zo" title="' + esc(T.zoom_out) + '">－</button>' +
-            '<button type="button" data-act="reset" title="' + esc(T.reset) + '">↺</button>' +
-          '</div>' +
-          '<div class="imed-actions">' +
-            '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary">' + esc(T.cancel) + '</button>' +
-            '<button type="button" data-act="apply" class="pe-btn pe-btn-primary">' + esc(T.apply) + '</button>' +
-          '</div>' +
-        '</div>';
-      document.body.appendChild(back);
-      const img = back.querySelector('img');
-      const url = URL.createObjectURL(file);
-      let cropper = null, sx = 1, sy = 1;
-
-      function cleanup() {
-        try { if (cropper) cropper.destroy(); } catch (_) {}
-        URL.revokeObjectURL(url);
-        back.remove();
-        document.removeEventListener('keydown', onKey);
-      }
-      function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } }
-      document.addEventListener('keydown', onKey);
-
-      img.onload = () => {
-        cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true });
-      };
-      img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original
-      img.src = url;
-
-      back.addEventListener('click', (e) => {
-        const btn = e.target.closest('[data-act]');
-        if (e.target === back) { cleanup(); resolve(null); return; }
-        if (!btn || !cropper) return;
-        const a = btn.getAttribute('data-act');
-        if (a === 'rl') cropper.rotate(-90);
-        else if (a === 'rr') cropper.rotate(90);
-        else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); }
-        else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); }
-        else if (a === 'zi') cropper.zoom(0.1);
-        else if (a === 'zo') cropper.zoom(-0.1);
-        else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); }
-        else if (a === 'cancel') { cleanup(); resolve(null); }
-        else if (a === 'apply') {
-          const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
-          const png = (file.type === 'image/png' || file.type === 'image/webp');
-          const mime = png ? 'image/png' : 'image/jpeg';
-          const ext = png ? '.png' : '.jpg';
-          canvas.toBlob((blob) => {
-            cleanup();
-            if (!blob) { resolve(file); return; }
-            const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, '');
-            resolve(new File([blob], base + ext, { type: mime }));
-          }, mime, 0.92);
-        }
-      });
-    });
-  }
-
-  function showCoverPreview(url) {
-    if (!coverWrap || !coverImg) return;
-    if (url) {
-      coverImg.src = url;
-      coverImg.hidden = false;
-      coverWrap.removeAttribute('data-empty');
-      const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
-      if (emptyIcon) emptyIcon.remove();
-    } else {
-      coverImg.hidden = true;
-      coverImg.src = '';
-      coverWrap.setAttribute('data-empty', '');
-      if (!coverWrap.querySelector('.pe-cover-empty')) {
-        const span = document.createElement('span');
-        span.className = 'pe-cover-empty';
-        span.textContent = '🖼';
-        coverWrap.appendChild(span);
-      }
-    }
-  }
-
-  if (coverTrigger && coverField) {
-    coverTrigger.addEventListener('click', () => coverField.click());
-  }
-  if (coverField) {
-    coverField.addEventListener('change', async () => {
-      if (!coverField.files[0]) return;
-      const edited = await openImageEditor(coverField.files[0]);
-      coverField.value = '';
-      if (!edited) return; // cancelled
-      coverStatus.classList.remove('is-error');
-      coverStatus.textContent = '' + esc(T.js_uploading) + '';
-      try {
-        const j = await uploadImage(edited);
-        coverUrl.value = j.url;
-        if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover
-        showCoverPreview(j.url);
-        coverStatus.textContent = (j.video ? '🎬 ' : '') + '' + esc(T.js_uploaded) + ' ✓';
-        setTimeout(() => { coverStatus.textContent = ''; }, 2000);
-      } catch (e) {
-        coverStatus.classList.add('is-error');
-        coverStatus.textContent = '' + esc(T.js_failed) + ': ' + e.message;
-      }
-    });
-  }
-  // Live-update preview when user pastes a URL manually
-  if (coverUrl) {
-    coverUrl.addEventListener('input', () => {
-      const v = coverUrl.value.trim();
-      if (v) showCoverPreview(v); else showCoverPreview('');
-    });
-  }
-
-  // ── WYSIWYG editor (P58) ────────────────────────────────────────
-  // Architecture:
-  //   - Visible <div contenteditable> (`#content-editor`) is what the user
-  //     types in; it shows real HTML (formatted, not raw markup).
-  //   - Hidden <input name="content"> (`#content-hidden`) is what submits.
-  //     On submit we serialize the editor's HTML into it, with shortcode
-  //     chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
-  //   - Initial content comes from a <script type="application/json"> tag
-  //     to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
-  //     and walk text nodes to render shortcode tokens as chips.
-  const contentField  = document.getElementById('content-upload-field');
-  const contentBtn    = document.getElementById('insert-image-btn');
-  const contentStatus = document.getElementById('content-upload-status');
-  const editor        = document.getElementById('content-editor');
-  const hiddenField   = document.getElementById('content-hidden');
-  const charCountEl   = document.getElementById('char-count');
-  const initialEl     = document.getElementById('initial-content');
-  const toolbar       = document.getElementById('pe-toolbar');
-  const form          = editor && editor.closest('form');
-
-  if (!editor) return;
-
-  // Auto-focus the title only on desktop (mouse/trackpad). On touch this would
-  // immediately open the keyboard when the editor opens — not desired.
-  try {
-    const titleInput = form && form.querySelector('input[name="title"]');
-    if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
-      titleInput.focus({ preventScroll: true });
-    }
-  } catch (_) {}
-
-  // ── Shortcode chip rendering / serialization ────────────────────
-  // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
-  // — but we DON'T want to chipify text the user is mid-typing inside an
-  // HTML attribute; since chipify only walks text nodes (never attribute
-  // values) that's already safe.
-  const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
-
-  const SC_ICONS = {
-    track:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>',
-    album:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>',
-    playlist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>',
-    embed:    '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>',
-  };
-
-  function chipLabel(kind, value) {
-    if (kind === 'track') {
-      // UUIDs are noisy — show a 6-char prefix for visual hint
-      const v = String(value || '');
-      return '' + esc(T.chip_track) + ' ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
-    }
-    if (kind === 'album')    return '' + esc(T.chip_album) + ' ' + value;
-    if (kind === 'playlist') return '' + esc(T.chip_playlist) + ' ' + value;
-    if (kind === 'embed') {
-      const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
-      return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean);
-    }
-    return value;
-  }
-
-  function makeChip(kind, value) {
-    const span = document.createElement('span');
-    span.className = 'sc-chip';
-    span.contentEditable = 'false';
-    span.setAttribute('data-sc', kind + ':' + value);
-    span.innerHTML =
-      '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
-      '<span class="sc-chip-label"></span>';
-    span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
-    return span;
-  }
-
-  // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
-  function chipifyShortcodes(root) {
-    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
-    const targets = [];
-    while (walker.nextNode()) {
-      const n = walker.currentNode;
-      // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
-      if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
-      if (SC_RE.test(n.nodeValue)) targets.push(n);
-      SC_RE.lastIndex = 0;
-    }
-    for (const node of targets) {
-      const txt = node.nodeValue;
-      const frag = document.createDocumentFragment();
-      let last = 0;
-      let m;
-      SC_RE.lastIndex = 0;
-      while ((m = SC_RE.exec(txt)) !== null) {
-        if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
-        frag.appendChild(makeChip(m[1], m[2].trim()));
-        last = m.index + m[0].length;
-      }
-      if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
-      node.parentNode.replaceChild(frag, node);
-    }
-  }
-
-  // Inverse of chipify: clone the editor, replace every chip with its text.
-  function serializeChips(rootClone) {
-    const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
-    for (const c of chips) {
-      const txt = '[[' + c.getAttribute('data-sc') + ']]';
-      c.replaceWith(document.createTextNode(txt));
-    }
-  }
-
-  // ── Boot: load initial content as HTML, then render shortcodes as chips
-  try {
-    const initial = JSON.parse(initialEl.textContent || '""');
-    editor.innerHTML = initial || '';
-    chipifyShortcodes(editor);
-  } catch (e) {
-    console.error('[editor] could not parse initial content', e);
-    editor.innerHTML = '';
-  }
-
-  // ── Char counter
-  function updateCharCount() {
-    const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
-    if (charCountEl) charCountEl.textContent = String(text.length);
-  }
-  updateCharCount();
-  editor.addEventListener('input', updateCharCount);
-
-  // ── Toolbar wiring
-  // Lock the scroll position around an edit command. execCommand/insert scrolls
-  // the caret into view by default → the view "jumps" when clicking a formatting
-  // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …)
-  // + the page and restore them — sync and over a few frames, because Chrome
-  // sometimes scrolls a frame later. The user scrolls themselves.
-  function scrollableAncestors(el) {
-    const list = [];
-    let node = el;
-    while (node && node !== document.body && node !== document.documentElement) {
-      const oy = getComputedStyle(node).overflowY;
-      if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node);
-      node = node.parentElement;
-    }
-    return list;
-  }
-  function keepScroll(fn) {
-    // In fullscreen the page is locked (body overflow:hidden) and the field may
-    // scroll to the caret freely — no page jump possible, so nothing to fix.
-    const frame = document.querySelector('.pe-editor-frame');
-    if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
-    const wx = window.scrollX, wy = window.scrollY;
-    const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; });
-    const restore = function () {
-      window.scrollTo(wx, wy);
-      anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; });
-    };
-    fn();
-    restore();
-    requestAnimationFrame(restore);
-  }
-  function execCmd(cmd, arg) {
-    keepScroll(function () {
-      editor.focus({ preventScroll: true });
-      document.execCommand(cmd, false, arg);
-    });
-    updateToolbarState();
-    updateCharCount();
-  }
-  function wrapCode() {
-    const sel = window.getSelection();
-    if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
-    keepScroll(function () {
-      const range = sel.getRangeAt(0);
-      const code = document.createElement('code');
-      code.textContent = sel.toString();
-      range.deleteContents();
-      range.insertNode(code);
-      // Move caret after the new node
-      range.setStartAfter(code);
-      range.collapse(true);
-      sel.removeAllRanges();
-      sel.addRange(range);
-      editor.focus({ preventScroll: true });
-    });
-  }
-  function linkPrompt() {
-    const url = window.prompt('' + esc(T.js_link_prompt) + '');
-    if (!url) return;
-    execCmd('createLink', url);
-  }
-  // Is the current selection inside a <blockquote> within the editor? Return it.
-  function blockquoteAncestor() {
-    const sel = window.getSelection();
-    if (!sel || sel.rangeCount === 0) return null;
-    let node = sel.anchorNode;
-    while (node && node !== editor) {
-      if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node;
-      node = node.parentNode;
-    }
-    return null;
-  }
-  // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but
-  // can never turn it OFF (browser quirk). If the caret is already in a quote →
-  // unwrap it; otherwise apply blockquote.
-  function toggleBlockquote() {
-    keepScroll(function () {
-      editor.focus({ preventScroll: true });
-      const bq = blockquoteAncestor();
-      if (bq) {
-        const parent = bq.parentNode;
-        // Extract content from the quote in place, then remove the empty wrapper.
-        const ref = bq;
-        let firstMoved = null;
-        while (bq.firstChild) {
-          const child = bq.firstChild;
-          if (!firstMoved) firstMoved = child;
-          parent.insertBefore(child, ref);
-        }
-        parent.removeChild(bq);
-        // Restore the caret inside the unwrapped content.
-        if (firstMoved) {
-          const sel = window.getSelection();
-          const range = document.createRange();
-          range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent);
-          range.collapse(false);
-          sel.removeAllRanges();
-          sel.addRange(range);
-        }
-      } else {
-        document.execCommand('formatBlock', false, 'blockquote');
-      }
-    });
-    updateToolbarState();
-    updateCharCount();
-  }
-
-  if (toolbar) {
-    // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection
-    // from the editor field. Without this the selection is lost on tap
-    // → execCommand operates on an empty selection (bold can no longer be toggled OFF)
-    // and the browser scrolls the caret back into view (the "jump down"). preventDefault
-    // on mousedown keeps focus in the editor; the click still fires normally.
-    toolbar.addEventListener('mousedown', (e) => {
-      if (e.target.closest('button')) e.preventDefault();
-    });
-    toolbar.addEventListener('click', (e) => {
-      const btn = e.target.closest('button[data-cmd]');
-      if (!btn) return;
-      e.preventDefault();
-      const cmd = btn.dataset.cmd;
-      const arg = btn.dataset.arg || null;
-      if (cmd === 'link-prompt') linkPrompt();
-      else if (cmd === 'code-wrap') wrapCode();
-      else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote();
-      else execCmd(cmd, arg);
-    });
-  }
-
-  // ── Full-screen writing mode: the writing field fills the whole page.
-  const fsBtn = document.getElementById('pe-fullscreen-btn');
-  const editorFrame = document.querySelector('.pe-editor-frame');
-  const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
-  // OP TOUCH IS DIT GEEN LUXE MAAR DE ENIGE INGANG (shaer-kd1). Buiten
-  // fullscreen is de toolbar daar verborgen (@media (pointer: coarse) in
-  // pages/post-edit.ejs), en het veld staat op contenteditable=false. Ontbreekt
-  // een van deze twee elementen, dan kun je op een telefoon NIET TYPEN -- en tot
-  // nu toe gebeurde dat zonder één spoor: applyFs deed een kale `return`.
-  if (isTouch && (!editorFrame || !editor)) {
-    console.warn('[post-edit] fullscreen onbereikbaar op touch:',
-      'frame=' + !!editorFrame, 'editor=' + !!editor,
-      '-- de toolbar is hier verborgen, dus dit betekent: niet kunnen typen');
-  }
-
-  // On mobile the keyboard pushes the visible (visual) viewport up while
-  // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar
-  // slides out of view. Keep the fullscreen frame aligned to the visual
-  // viewport (top + height) so the toolbar stays visible at the top.
-  function syncFsViewport() {
-    if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
-    const vv = window.visualViewport;
-    if (!vv) return;
-    editorFrame.style.top = vv.offsetTop + 'px';
-    editorFrame.style.height = vv.height + 'px';
-  }
-  function clearFsViewport() {
-    if (!editorFrame) return;
-    editorFrame.style.top = '';
-    editorFrame.style.height = '';
-  }
-  function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); }
-  function applyFs(on) {
-    if (!editorFrame) {
-      // Was een kale `return`. Op touch is dit het verschil tussen "fullscreen
-      // werkt niet" en "je kunt niet typen", en het gebeurde zonder spoor.
-      console.warn('[post-edit] fullscreen kan niet: .pe-editor-frame ontbreekt');
-      return;
-    }
-    editorFrame.classList.toggle('pe-fs', on);
-    document.body.classList.toggle('pe-fs-open', on);
-    document.documentElement.classList.toggle('pe-fs-open', on);
-    if (fsBtn) {
-      fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
-      fsBtn.title = on ? '' + esc(T.tb_done) + '' : '' + esc(T.tb_fullscreen) + '';
-    }
-    if (window.visualViewport) {
-      if (on) {
-        window.visualViewport.addEventListener('resize', syncFsViewport);
-        window.visualViewport.addEventListener('scroll', syncFsViewport);
-        syncFsViewport();
-      } else {
-        window.visualViewport.removeEventListener('resize', syncFsViewport);
-        window.visualViewport.removeEventListener('scroll', syncFsViewport);
-        clearFsViewport();
-      }
-    }
-    // On touch the field is NOT editable inline; only in fullscreen.
-    if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
-    if (on) {
-      editor.focus({ preventScroll: true });
-    } else {
-      if (isTouch) editor.blur();
-      // On close: scroll to the TOP of the content instead of staying
-      // somewhere at the bottom (footer).
-      requestAnimationFrame(function () {
-        try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
-      });
-    }
-  }
-  // The fullscreen writing "page": opening pushes a history state so the browser
-  // back button (and the Done button) closes it and returns you to the form — feels
-  // like a separate page, but all form fields remain intact (same DOM).
-  function openFs() {
-    if (isFs()) return;
-    try { history.pushState({ peFs: true }, ''); } catch (_) {}
-    applyFs(true);
-  }
-  function closeFs() {
-    if (!isFs()) return;
-    if (history.state && history.state.peFs) history.back(); // → popstate closes it
-    else applyFs(false);
-  }
-  function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); }
-  doc.on(window, 'popstate', function () { if (isFs()) applyFs(false); });
-  // __wired zoals overal in run(): init() draait bij ELKE paginawissel, en op
-  // dezelfde DOM zou een kale addEventListener stapelen. Na een htmx-wissel is
-  // het element nieuw en dus de vlag weg -- precies de bedoeling.
-  if (fsBtn && !fsBtn.__fsWired) { fsBtn.__fsWired = true; fsBtn.addEventListener('click', toggleFullscreen); }
-  var fsDoneBtn = document.getElementById('pe-fs-done');
-  if (fsDoneBtn && !fsDoneBtn.__fsWired) { fsDoneBtn.__fsWired = true; fsDoneBtn.addEventListener('click', closeFs); }
-  doc.on(document, 'keydown', (e) => {
-    if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); }
-  });
-
-  // On mobile/tablet (touch): the content field is NOT editable inline — it is
-  // not a text field there. One tap → fullscreen, where it becomes editable
-  // (toggleFullscreen toggles contenteditable). This prevents inline typing.
-  if (isTouch && editor && !editor.__fsTapWired) {
-    editor.__fsTapWired = true;
-    editor.setAttribute('contenteditable', 'false');
-    editor.classList.add('pe-tap-to-edit');
-    editor.addEventListener('click', function () {
-      if (!isFs()) openFs();
-    });
-  }
-
-  // Reflect bold/italic/list state on the toolbar buttons
-  function updateToolbarState() {
-    if (!toolbar) return;
-    const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
-    for (const cmd of cmds) {
-      const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
-      if (!btn) continue;
-      try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
-    }
-    // Quote button: active when the caret is inside a <blockquote> (toggle feedback).
-    const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
-    if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
-  }
-  doc.on(document, 'selectionchange', () => {
-    if (document.activeElement === editor) updateToolbarState();
-  });
-
-  // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
-  editor.addEventListener('keydown', (e) => {
-    const mod = e.ctrlKey || e.metaKey;
-    if (!mod) return;
-    const k = e.key.toLowerCase();
-    if (k === 'b') { e.preventDefault(); execCmd('bold'); }
-    else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
-    else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
-    else if (k === 'k') { e.preventDefault(); linkPrompt(); }
-  });
-
-  // Paste: keep it simple — strip formatting unless user wants it. Default
-  // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
-  // styles from clipboard only when shift is held — otherwise plain text.
-  editor.addEventListener('paste', (e) => {
-    if (e.shiftKey) return; // user wants formatted paste
-    const text = (e.clipboardData || window.clipboardData).getData('text/plain');
-    if (text == null) return;
-    e.preventDefault();
-    document.execCommand('insertText', false, text);
-  });
-
-  // ── Image upload (button + drag-drop into the editor)
-  async function uploadAndInsertImage(file) {
-    const edited = await openImageEditor(file);
-    if (!edited) return; // cancelled
-    contentStatus.classList.remove('is-error');
-    contentStatus.textContent = '' + esc(T.js_uploading) + '';
-    try {
-      const j = await uploadImage(edited);
-      const img = '<img src="' + j.url + '" alt="">';
-      editor.focus({ preventScroll: true });
-      document.execCommand('insertHTML', false, img);
-      contentStatus.textContent = '' + esc(T.js_inserted) + ' ✓';
-      setTimeout(() => { contentStatus.textContent = ''; }, 2000);
-      updateCharCount();
-    } catch (e) {
-      contentStatus.classList.add('is-error');
-      contentStatus.textContent = '' + esc(T.js_failed) + ': ' + e.message;
-    }
-  }
-
-  if (contentBtn && contentField) {
-    contentBtn.addEventListener('click', () => contentField.click());
-    contentField.addEventListener('change', () => {
-      if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
-      contentField.value = '';
-    });
-
-    editor.addEventListener('dragover', (e) => {
-      if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
-        e.preventDefault();
-        editor.classList.add('is-dragover');
-      }
-    });
-    editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
-    editor.addEventListener('drop', async (e) => {
-      editor.classList.remove('is-dragover');
-      const files = e.dataTransfer && e.dataTransfer.files;
-      if (!files || !files.length) return;
-      e.preventDefault();
-      for (const f of files) {
-        if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
-      }
-    });
-  }
-
-  // ── Insert chip helpers (track / playlist)
-  function insertChip(kind, value) {
-    editor.focus({ preventScroll: true });
-    const chip = makeChip(kind, value);
-    // Insert at caret using the Selection API (execCommand insertNode)
-    const sel = window.getSelection();
-    if (sel && sel.rangeCount > 0) {
-      const range = sel.getRangeAt(0);
-      range.deleteContents();
-      range.insertNode(chip);
-      // Insert a trailing space so the user can keep typing after the chip
-      const space = document.createTextNode('\u00A0');
-      chip.after(space);
-      range.setStartAfter(space);
-      range.collapse(true);
-      sel.removeAllRanges();
-      sel.addRange(range);
-    } else {
-      editor.appendChild(chip);
-      editor.appendChild(document.createTextNode('\u00A0'));
-    }
-    updateCharCount();
-    volgMuziek();
-  }
-
-  // Wordt hieronder gevuld door het typeblok. Zolang dat er niet is (of de
-  // gebruiker het type zelf koos) gebeurt er niets -- insertChip mag daar niet
-  // op stuklopen.
-  let volgMuziek = () => {};
-
-  // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
-  //    an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
-  const embedBtn = document.getElementById('insert-embed-btn');
-  if (embedBtn) {
-    embedBtn.addEventListener('click', () => {
-      const raw = window.prompt('' + esc(T.js_embed_prompt) + '');
-      if (!raw) return;
-      const url = raw.trim();
-      if (!/^https?:\/\//i.test(url)) { alert('' + esc(T.js_embed_invalid) + ''); return; }
-      insertChip('embed', url);
-    });
-  }
-
-  // ── Track insert: opens the track-picker modal (P59)
-  const trackBtn = document.getElementById('insert-track-btn');
-  const trackPicker = document.getElementById('track-picker');
-  if (trackBtn && trackPicker) {
-    const tpList   = document.getElementById('tp-list');
-    const tpEmpty  = document.getElementById('tp-empty');
-    const tpSearch = document.getElementById('tp-search');
-    let tpCache = null;       // cached track list (fetched once per page load)
-    let tpLastFocus = null;   // element to restore focus to on close
-
-    const SVG_NOTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>';
-
-    function fmtDur(sec) {
-      sec = Math.max(0, Math.floor(sec || 0));
-      const m = Math.floor(sec / 60), s = sec % 60;
-      return m + ':' + String(s).padStart(2, '0');
-    }
-    function escAttr(s) {
-      return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
-        '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
-      }[c]));
-    }
-
-    function renderList(filter) {
-      if (!Array.isArray(tpCache)) return;
-      const q = (filter || '').trim().toLowerCase();
-      const filtered = q
-        ? tpCache.filter(t =>
-            (t.title  || '').toLowerCase().includes(q) ||
-            (t.artist || '').toLowerCase().includes(q))
-        : tpCache;
-
-      if (!filtered.length) {
-        tpList.innerHTML = '';
-        tpEmpty.textContent = q ? '' + esc(T.js_no_tracks_found) + ' ' + q : '' + esc(T.js_no_tracks_yet) + '';
-        tpList.appendChild(tpEmpty);
-        return;
-      }
-
-      tpList.innerHTML = filtered.map(t => {
-        const cov = t.cover
-          ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
-          : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
-        const dis = t.playable ? '' : ' aria-disabled="true"';
-        const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
-        return (
-          '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
-            cov +
-            '<span class="tp-meta">' +
-              '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
-              sub +
-            '</span>' +
-            '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
-          '</button>'
-        );
-      }).join('');
-    }
-
-    async function loadTracks() {
-      if (Array.isArray(tpCache)) return tpCache;
-      tpEmpty.textContent = '' + esc(T.js_tracks_loading) + '';
-      try {
-        const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
-        const j = await r.json();
-        tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
-      } catch (e) {
-        tpCache = [];
-        tpEmpty.textContent = '' + esc(T.js_tracks_load_fail) + ': ' + e.message;
-      }
-      return tpCache;
-    }
-
-    function openPicker() {
-      tpLastFocus = document.activeElement;
-      trackPicker.hidden = false;
-      trackPicker.setAttribute('aria-hidden', 'false');
-      document.body.classList.add('tp-locked');
-      tpSearch.value = '';
-      renderList('');
-      // Defer focus so the open animation doesn't get jumped
-      setTimeout(() => tpSearch.focus(), 30);
-    }
-    function closePicker() {
-      trackPicker.hidden = true;
-      trackPicker.setAttribute('aria-hidden', 'true');
-      document.body.classList.remove('tp-locked');
-      if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
-        try { tpLastFocus.focus(); } catch(_) {}
-      }
-    }
-
-    trackBtn.addEventListener('click', async () => {
-      openPicker();
-      await loadTracks();
-      renderList(tpSearch.value);
-    });
-
-    // Close: backdrop click, [data-tp-close], or Escape
-    trackPicker.addEventListener('click', (e) => {
-      if (e.target.closest('[data-tp-close]')) {
-        closePicker();
-        return;
-      }
-      const row = e.target.closest('.tp-row[data-track-id]');
-      if (row) {
-        if (row.getAttribute('aria-disabled') === 'true') return;
-        const id = row.dataset.trackId;
-        if (id) {
-          insertChip('track', id);
-          closePicker();
-        }
-      }
-    });
-    doc.on(document, 'keydown', (e) => {
-      if (!trackPicker.hidden && e.key === 'Escape') {
-        e.preventDefault();
-        closePicker();
-      }
-    });
-
-    // Live filter
-    tpSearch.addEventListener('input', () => renderList(tpSearch.value));
-  }
-
-  // ── Playlist insert (open existing or create new via modal)
-  const playlistBtn = document.getElementById('insert-playlist-btn');
-  if (playlistBtn) {
-    playlistBtn.addEventListener('click', async () => {
-      if (typeof window.openPlaylistEditor !== 'function') {
-        alert('' + esc(T.js_playlist_editor_missing) + '');
-        return;
-      }
-      try {
-        const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
-        const j = await r.json();
-        if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
-          const choice = prompt(
-            '' + esc(T.js_playlist_existing) + '\n\n' +
-            j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
-            '\n\n' + esc(T.js_playlist_choose) + ''
-          );
-          if (choice && /^\d+$/.test(choice.trim())) {
-            const idx = parseInt(choice.trim(), 10) - 1;
-            if (idx >= 0 && idx < j.playlists.length) {
-              insertChip('playlist', j.playlists[idx].id);
-              return;
-            }
-          }
-          if (choice === null) return;
-        }
-      } catch (_) { /* fall through to create */ }
-
-      window.openPlaylistEditor({
-        mode: 'create',
-        onSaved: ({ id }) => insertChip('playlist', id),
-      });
-    });
-  }
-
-  // ── Post type: segmented control + type-aware panels ──────────
-  (function () {
-    const typeInput = document.getElementById('pe-type-input');
-    const card = document.querySelector('.pe-type-card');
-    if (!typeInput || !card) return;
-    const seg = card.querySelector('.pe-typeseg');
-    const panels = card.querySelectorAll('.pe-type-panel');
-    let handmatig = false;   // heeft de gebruiker het type zelf aangeklikt?
-
-    function applyType(tt) {
-      typeInput.value = tt;
-      seg.querySelectorAll('.pe-typeseg-btn').forEach(b => {
-        const on = b.dataset.type === tt;
-        b.classList.toggle('is-active', on);
-        b.setAttribute('aria-checked', on ? 'true' : 'false');
-      });
-      // data-panel mag meerdere types noemen: Album en Playlist delen het
-      // muziekpaneel, want het verschil zit in de playlist en niet in de upload.
-      panels.forEach(p => {
-        const voor = String(p.dataset.panel || '').trim().split(/\s+/);
-        p.hidden = !voor.includes(tt);
-      });
-    }
-    seg.addEventListener('click', (e) => {
-      const btn = e.target.closest('.pe-typeseg-btn');
-      if (!btn) return;
-      handmatig = true;          // jouw klik wint vanaf nu van de afleiding
-      applyType(btn.dataset.type);
-    });
-    applyType(typeInput.value || 'post');
-
-    // ── Het type volgt de muziek (shaer-cyg) ──────────────────────
-    //
-    // Robins regel: de post neemt de soort van zijn muziek over als hij precies
-    // EEN muzikale eenheid bevat. Dus zodra je een playlist invoegt verspringt
-    // de balk mee, en zie je wat je aan het maken bent in plaats van het zelf te
-    // moeten bijhouden.
-    //
-    // TWEE DINGEN DIE HIJ MET RUST LAAT. Klik je zelf een type aan, dan wint die
-    // keuze -- daarna wordt er niets meer voor je omgezet. En Foto en Video zijn
-    // een bewuste andere keuze over dezelfde post, dus die overschrijft hij
-    // nooit, ook niet als er muziek in staat.
-    //
-    // Bij het OPENEN van een post gebeurt er niets: de eerste afleiding wordt
-    // alleen als ijkpunt onthouden. Anders zou een oude post van type veranderen
-    // door hem te bekijken.
-    const VOLGBAAR = new Set(['post', 'album', 'playlist', 'mixtape', 'audio']);
-    const kindVan = new Map();
-    let vorigeAfleiding = null;
-
-    const soortenGeladen = fetch('/admin/playlists/api/list', { credentials: 'same-origin' })
-      .then((r) => r.json())
-      .then((j) => {
-        if (j && j.ok && Array.isArray(j.playlists)) {
-          // De soort ZOALS HIJ IS. Dit was `=== 'playlist' ? 'playlist' : 'album'`,
-          // en dan ziet de editor een mixtape als album terwijl de server hem
-          // als mixtape opslaat: het type verspringt onder je handen bij het
-          // bewaren. SOORTEN is dezelfde lijst die de server gebruikt.
-          for (const p of j.playlists) kindVan.set(p.id, SOORTEN.includes(p.kind) ? p.kind : 'album');
-        }
-      })
-      // Zonder de soorten leidt de regel niets af (elke playlist is dan
-      // 'onbekend'), en dat is beter dan een gok die als keuze oogt.
-      .catch(() => {})
-      .then(() => { vorigeAfleiding = afleidenNu(); });
-
-    function afleidenNu() {
-      // Precies de tekst die straks wordt opgeslagen: chips terug naar
-      // shortcodes. Zo leidt de editor af uit wat de server ook zal zien.
-      const clone = editor.cloneNode(true);
-      serializeChips(clone);
-      const r = afleidenUitInsluitingen(clone.innerHTML, (id) => kindVan.get(id) || null);
-      return r ? r.type : null;
-    }
-
-    volgMuziek = async () => {
-      await soortenGeladen;
-      const nu = afleidenNu();
-      if (nu === vorigeAfleiding) return;    // de muziek is niet van soort veranderd
-      vorigeAfleiding = nu;
-      if (handmatig || !nu) return;
-      if (!VOLGBAAR.has(typeInput.value)) return;
-      applyType(nu);
-    };
-
-    // Ook getypte of geplakte shortcodes tellen, niet alleen de knoppen.
-    let tik = null;
-    editor.addEventListener('input', () => {
-      clearTimeout(tik);
-      tik = setTimeout(volgMuziek, 300);
-    });
-
-    // Video URL → [[embed:url]] chip
-    const vBtn = document.getElementById('pe-video-insert');
-    const vUrl = document.getElementById('pe-video-url');
-    if (vBtn && vUrl) {
-      const doInsert = () => {
-        const url = (vUrl.value || '').trim();
-        if (!/^https?:\/\//i.test(url)) { alert('' + esc(T.js_embed_invalid) + ''); return; }
-        insertChip('embed', url);
-        vUrl.value = '';
-      };
-      vBtn.addEventListener('click', doInsert);
-      vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } });
-    }
-
-    // Audio: inline upload → transcodes server-side → [[track:id]] chip
-    const drop = document.getElementById('pe-audio-drop');
-    const fileInput = document.getElementById('pe-audio-file');
-    const list = document.getElementById('pe-audio-list');
-    if (drop && fileInput && list) {
-      const pick = () => fileInput.click();
-      drop.addEventListener('click', pick);
-      drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
-      ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); }));
-      ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); }));
-      drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); });
-      fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; });
-
-      function clientDuration(f) {
-        return new Promise((resolve) => {
-          try {
-            const u = URL.createObjectURL(f);
-            const a = document.createElement('audio');
-            a.preload = 'metadata';
-            a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); };
-            a.onerror = () => { URL.revokeObjectURL(u); resolve(null); };
-            a.src = u;
-          } catch (_) { resolve(null); }
-        });
-      }
-      async function handleFiles(files) {
-        for (const f of Array.from(files || [])) await uploadOne(f);
-      }
-      async function uploadOne(f) {
-        const li = document.createElement('li');
-        li.className = 'pe-audio-item';
-        const nameEl = document.createElement('span');
-        nameEl.className = 'pe-audio-item-name';
-        nameEl.textContent = f.name;
-        const stateEl = document.createElement('span');
-        stateEl.className = 'pe-audio-item-state';
-        stateEl.textContent = '⏳ ' + esc(T.audio_up_busy) + '';
-        li.appendChild(nameEl); li.appendChild(stateEl);
-        list.appendChild(li);
-        try {
-          const dur = await clientDuration(f);
-          const fd = new FormData();
-          fd.append('audio', f);
-          if (dur) fd.append('duration', String(dur));
-          const res = await fetch('/admin/audio/upload', {
-            method: 'POST', body: fd,
-            headers: { 'Accept': 'application/json' },
-            credentials: 'same-origin',
-          });
-          const j = await res.json().catch(() => ({}));
-          if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status));
-          insertChip('track', j.id);
-          stateEl.textContent = '✓ ' + esc(T.audio_up_done) + '';
-          li.classList.add('is-done');
-        } catch (err) {
-          stateEl.textContent = '✕ ' + esc(T.audio_up_fail) + ': ' + err.message;
-          li.classList.add('is-fail');
-        }
-      }
-    }
-  })();
-
-  // ── Submit: serialize editor contents into the hidden field
-  if (form && hiddenField) {
-    form.addEventListener('submit', () => {
-      const clone = editor.cloneNode(true);
-      serializeChips(clone);
-      hiddenField.value = clone.innerHTML;
-    });
-  }
-})();
-
-// ── volgend blok ──
-
-(function () {
-  // Pin: checkbox toggles the hidden rank field (0 = not pinned),
-  // ▲▼ shifts the position, with a readable description instead of a raw number.
-  var toggle = document.getElementById('pin-toggle');
-  var rank   = document.getElementById('pin-rank');
-  var pos    = document.getElementById('pin-pos');
-  var label  = document.getElementById('pin-label');
-  var up     = document.getElementById('pin-up');    // higher = lower number (towards 1/top)
-  var down   = document.getElementById('pin-down');
-  if (!toggle || !rank || !pos) return;
-
-  function descr(n) {
-    n = Number(n) || 0;
-    if (n <= 1) return '' + esc(T.pin_top) + '';
-    return n + '' + esc(T.pin_nth_suffix) + '';
-  }
-  function render() {
-    var on = toggle.checked;
-    pos.hidden = !on;
-    if (on && Number(rank.value) < 1) rank.value = 1;
-    if (!on) rank.value = 0;
-    if (label) label.textContent = on ? descr(rank.value) : '';
-    if (up) up.disabled = Number(rank.value) <= 1;
-  }
-  toggle.addEventListener('change', render);
-  if (up)   up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
-  if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
-  render();
-})();
-
-(function () {
-  // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible
-  // obstacles by setting a dynamic bottom offset = the greater of:
-  //  1) the height of the keyboard area NOT covered by the layout viewport
-  //     (on iOS the visual viewport shifts; on Android the layout viewport shrinks
-  //     due to interactive-widget=resizes-content → offset ≈ 0);
-  //  2) the height of the playing audio player (fixed, z-index 1000).
-  // We stick with sticky (no fixed/top tricks → no bar floating in the middle).
-  var bar = document.querySelector('.pe-actions');
-  if (!bar) return;
-  var vv = window.visualViewport;
-  function position() {
-    var ap = document.querySelector('.audio-player');
-    var playing = document.body.classList.contains('has-audio-player') &&
-                  ap && getComputedStyle(ap).display !== 'none';
-    var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
-    var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
-    var offset = Math.max(audioOffset, kbCovered);
-    bar.style.bottom = offset ? offset + 'px' : '';
-  }
-  position();
-  doc.on(window, 'resize', position);
-  if (vv) { doc.on(vv, 'resize', position); doc.on(vv, 'scroll', position); }
-  // has-audio-player is toggled via a body class → observe it. De observer
-  // overleeft de swap net als de listeners; init() disconnect de vorige.
-  try { _barObserver = new MutationObserver(position); _barObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
-})();
-}
Index: src/assets/js/mod/post.js
===================================================================
--- src/assets/js/mod/post.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,57 +1,0 @@
-// De post-pagina: het "beantwoorden vanaf je eigen server"-veld (shaer-bqr).
-//
-// Stond inline in pages/post.ejs. Kwam je op een post via een link BINNEN de
-// site, dan arriveerde dat script via htmx met een nonce die het document niet
-// kent en weigerde de CSP het (shaer-0i6) -- dus deed de knop niets, precies op
-// de plek waar de meeste bezoekers binnenkomen.
-//
-// Kon ongewijzigd mee: alles is al gedelegeerd op document, er wordt geen enkel
-// pagina-element vastgehouden, en er staat een slot op. Dat is niet toevallig --
-// het is geschreven voor een popover die na een htmx-swap moet blijven werken.
-
-  (function(){
-    if (window.__fediRemoteWired) return; window.__fediRemoteWired = true;
-    var current = null, currentBtn = null;
-    function close(){ if (current) { current.remove(); current = null; currentBtn = null; } }
-    function go(raw, uri){
-      var d = (raw||'').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
-      if (d) { try { localStorage.setItem('pcmsFediServer', d); } catch(e){} location.href = 'https://' + d + '/authorize_interaction?uri=' + encodeURIComponent(uri||''); }
-    }
-    function place(f, b){
-      var r = b.getBoundingClientRect();
-      f.style.top = (r.bottom + window.scrollY + 6) + 'px';
-      f.style.left = Math.max(8, Math.min(r.left + window.scrollX, window.scrollX + window.innerWidth - 340)) + 'px';
-    }
-    document.addEventListener('click', function(e){
-      if (e.target.closest && e.target.closest('.fedi-remote-cancel')) { close(); return; }
-      if (current && e.target.closest && e.target.closest('.fedi-remote-form')) return; // click inside → keep
-      var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn');
-      if (b) {
-        e.preventDefault();
-        if (currentBtn === b) { close(); return; }   // toggle off
-        close();
-        var f = document.createElement('form');
-        f.className = 'fedi-remote-form';
-        f.dataset.uri = b.getAttribute('data-fedi-uri') || '';
-        f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false">'
-          + '<button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">&rarr;</button>'
-          + '<button type="button" class="fedi-remote-cancel" aria-label="x">&times;</button>';
-        var _inp = f.querySelector('input');
-        _inp.placeholder = b.getAttribute('data-fedi-ph') || 'mastodon.social';
-        try { var _sv = localStorage.getItem('pcmsFediServer'); if (_sv) _inp.value = _sv; } catch(e){}
-        document.body.appendChild(f);            // floating popover → no layout reflow
-        place(f, b); current = f; currentBtn = b;
-        _inp.focus(); _inp.select();
-        return;
-      }
-      if (current) close(); // click anywhere else closes it
-    });
-    document.addEventListener('submit', function(e){
-      var f = e.target.closest && e.target.closest('.fedi-remote-form');
-      if (!f) return; e.preventDefault();
-      go(f.querySelector('input').value, f.dataset.uri);
-    });
-    document.addEventListener('keydown', function(e){ if (e.key === 'Escape') close(); });
-    window.addEventListener('scroll', close, true);
-  })();
-  
Index: src/assets/js/mod/read.js
===================================================================
--- src/assets/js/mod/read.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,663 +1,0 @@
-/**
- * De leesweergave: tikken op een bericht opent dat bericht, en een knop om terug
- * naar boven te gaan.
- *
- * Dit was een heel scherm met een eigen route, dat zijn buren zelf ophaalde, de
- * scrollpositie corrigeerde bij invoegen en de balken wegschoof. Dat is allemaal
- * weg, en dat is winst: Lezen is nu een WEERGAVE van de feed
- * (body[data-feed-view="reader"]), naast Tijdlijn en Grid. De feed levert de
- * berichten al, "meer laden" vult al aan, en het snappen naar berichtgrenzen
- * doet CSS.
- *
- * HET SNAPPEN STAAT HIER MET OPZET NIET IN. Ik heb dat een ronde lang wel
- * geprobeerd -- richting bijhouden, een vangzone uitrekenen, per scroll-event
- * het snappunt verzetten -- en dat is de verkeerde laag. Robins bezwaar (20-8):
- * "scroll-snap op het element is iets anders dan touch events,
- * requestAnimationFrame etc. dan gaan we te veel van de view doen." Klopt, en
- * het vocht ook met de browser: de scroll-events bevatten OOK de bewegingen van
- * zijn eigen snap-animatie, dus de richting die je eruit afleidt is niet die van
- * de gebruiker.
- *
- * Wat er nodig was, was een regel minder in de CSS en niet honderd erbij hier:
- * zie style.css bij .feed-reader .read-post.
- *
- * De titel en de voetlink in read-article.ejs zijn echte <a>'s en doen het werk
- * voor toetsenbord en schermlezer; de tik hieronder is er voor een duim.
- *
- * Vier uitzonderingen op die tik, want een tik die je niet bedoelde is erger dan
- * geen tik: iets dat zelf al een doel heeft (link, knop, veld) houdt zijn eigen
- * werking, een geselecteerde tekst is geen tik, een verschoven vinger is
- * scrollen, en cmd/ctrl-klik hoort de browser zelf af te handelen.
- */
-
-// VENDOR_V komt uit lib.js: een nummer voor de hele vendormap (shaer-724),
-// zodat 'ophogen als de bibliotheek wijzigt' op een plek staat.
-import { VENDOR_V } from './lib.js';
-
-// ── Paginamodus ─────────────────────────────────────────────────────────────
-/**
- * Elk bericht een paneel dat zelf scrollt; tussen berichten ga je met knoppen.
- *
- * Robins voorstel (20-8) nadat het namaken van iOS-momentum niet lukte, en dat
- * is de winst: in dit model DOET het scrollgevoel er niet toe. Er is geen
- * momentum om na te bouwen en geen snap die vangt.
- *
- * Hoe het werkt:
- *  - lenis.stop() zet het scrollen van de STROOM stil. Lenis vangt dan wiel en
- *    vinger af (hij preventDefault't in onVirtualScroll), dus je kunt niet meer
- *    tussen berichten door scrollen.
- *  - data-lenis-prevent op elk paneel houdt de scroll BINNEN een bericht
- *    ongemoeid. Dat werkt ook terwijl Lenis gestopt is, want die controle staat
- *    in zijn bron vóór de gestopt-controle. Nagekeken in de dist, niet gehoopt.
- *  - Bewegen tussen panelen gaat met lenis.scrollTo(..., { force: true }), want
- *    force is precies de uitzondering die een gestopte Lenis toch laat scrollen.
- *
- * Geldt waar een bericht een eigen scherm heeft: op mobiel altijd, op desktop
- * als de site dat instelt (reader_full_page). Buiten die modus verandert er
- * niets -- dan is Lenis gewoon aan en scrol je vrij.
- */
-/**
- * PAGINAMODUS STAAT UIT -- maar de code blijft staan (Robin, 20-8: "weghalen
- * maar bewaren, voor als ik het later weer wil").
- *
- * Wat het is: elk bericht een paneel van een scherm dat zelf scrolt, met twee
- * balken om ertussen te navigeren en de stroomscroll vastgezet. Gebouwd omdat
- * het namaken van iOS-momentum niet lukte; in dit model doet het scrollgevoel er
- * namelijk niet toe. Mobiel is nu terug op systeemscroll met snap, en dat is wat
- * er ook stond voordat we dit probeerden.
- *
- * Aanzetten: deze constante op true. Dan komen de balken terug (CSS hangt aan
- * body.is-paged), stopt Lenis de stroomscroll en scrollen de panelen zelf.
- */
-const PAGINAMODUS = false;
-
-function paginaModus() {
-  return PAGINAMODUS && OP_TOUCH.matches && document.body.dataset.feedView === 'reader';
-}
-
-function panelen() {
-  return [...document.querySelectorAll('.feed-reader .read-post')];
-}
-
-/** Welk paneel vult nu het scherm? Het eerste waarvan de bovenkant niet voorbij is. */
-function huidigIndex() {
-  const P = panelen();
-  for (let i = 0; i < P.length; i++) {
-    if (P[i].getBoundingClientRect().top > 8) return Math.max(0, i - 1);
-  }
-  return Math.max(0, P.length - 1);
-}
-
-function gaNaar(i) {
-  const P = panelen();
-  const doel = P[Math.max(0, Math.min(P.length - 1, i))];
-  if (!doel) return;
-  if (lenis) lenis.scrollTo(doel, { force: true });
-  else doel.scrollIntoView({ behavior: 'smooth', block: 'start' });
-  setTimeout(zetBalken, 80);
-}
-
-/**
- * Omhoog vanaf het EERSTE bericht brengt je naar de header.
- *
- * In paginamodus staat Lenis stil, dus de pagina scrolt niet meer met je vinger
- * -- en dan is alles boven het eerste bericht onbereikbaar. Robin liep daar
- * tegenaan (20-8): "ik kan niet meer terug scrollen naar de header". De
- * omhoog-knop is daar de enige weg naartoe, dus die krijgt er een trede bij.
- */
-function gaOmhoog() {
-  const i = huidigIndex();
-  if (i > 0) { gaNaar(i - 1); return; }
-  if (lenis) lenis.scrollTo(0, { force: true });
-  else window.scrollTo({ top: 0, behavior: 'smooth' });
-}
-
-/**
- * Welke balk hoort er te staan? Bovenaan de pagina is er niets boven je, dus dan
- * geen bovenbalk. Buiten paginamodus staan ze allebei niet -- de CSS verbergt ze
- * daar al, maar hidden houdt ze ook uit de toetsenbordvolgorde.
- */
-function zetBalken() {
-  const boven = document.getElementById('read-prev');
-  const onder = document.getElementById('read-next-nav');
-  const aan = paginaModus();
-  // De bovenbalk hoort NOOIT over de header te liggen. Hij verscheen al zodra je
-  // een paar pixels scrolde, en dekte dan de avatar, de omschrijving en de
-  // weergaveknoppen af (Robins schermafbeelding, 20-8). Nu komt hij pas als het
-  // eerste bericht de bovenrand van het scherm heeft bereikt -- dan is de header
-  // voorbij en is er ook echt iets om naar terug te gaan.
-  const eerste = panelen()[0];
-  const headerNogInBeeld = eerste ? eerste.getBoundingClientRect().top > 4 : true;
-  if (boven) boven.hidden = !aan || headerNogInBeeld;
-  if (onder) onder.hidden = !aan;
-  if (onder && aan) onder.style.bottom = onderChroom() + 'px';
-}
-
-/**
- * Hoe hoog staat de onderrand van het scherm werkelijk vol?
- *
- * De onderbalk stond op een geraden 4.75rem boven de onderkant, en dan zweeft
- * hij: soms een kier boven de speler, soms er half achter. De tabbalk en de
- * mini-speler hebben allebei een eigen hoogte, ze stapelen op mobiel, en de
- * speler komt en gaat. Dus meten in plaats van gokken: hoe ver ligt de BOVENKANT
- * van het hoogste vaste element boven de onderrand van het venster?
- */
-function onderChroom() {
-  // .bottom-tab-fab staat erbij omdat de Write-knop BOVEN de tabbalk uitsteekt:
-  // meet je alleen de balk, dan legt onze balk zich over die knop heen (Robins
-  // schermafbeelding, 20-8).
-  const kandidaten = ['.bottom-tab', '.bottom-tab-fab', '#pcms-audio-player'];
-  let hoogste = 0;
-  kandidaten.forEach((sel) => {
-    const el = document.querySelector(sel);
-    if (!el) return;
-    const st = getComputedStyle(el);
-    if (st.display === 'none' || st.visibility === 'hidden') return;
-    const r = el.getBoundingClientRect();
-    if (r.height <= 0) return;
-    hoogste = Math.max(hoogste, window.innerHeight - r.top);
-  });
-  return Math.round(hoogste);
-}
-
-function pasPaginaModusToe() {
-  const aan = paginaModus();
-  document.body.classList.toggle('is-paged', aan);
-  panelen().forEach((a) => {
-    if (aan) a.setAttribute('data-lenis-prevent', '');
-    else a.removeAttribute('data-lenis-prevent');
-  });
-  if (lenis) { if (aan) lenis.stop(); else lenis.start(); }
-  zetBalken();
-}
-
-/**
- * Terug naar boven, en bewust NIET window.scrollTo(0).
- *
- * In een stroom wil je terug naar het BEGIN VAN DIT BERICHT als je halverwege een
- * lang stuk zit, en pas daarna naar de kop van de pagina. Twee keer drukken doet
- * dus twee verschillende dingen -- dat scheelt op mobiel een hoop vegen.
- */
-function naarBoven() {
-  const zacht = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
-  const gedrag = zacht ? 'smooth' : 'auto';
-  const posts = [...document.querySelectorAll('.feed-reader .read-post')];
-  const huidig = posts.find((a) => {
-    const r = a.getBoundingClientRect();
-    return r.top <= 8 && r.bottom > 8;
-  });
-  // Sta je al bovenaan dit bericht (of bij het eerste), dan naar de paginakop.
-  if (huidig && huidig.getBoundingClientRect().top < -8) {
-    huidig.scrollIntoView({ behavior: gedrag, block: 'start' });
-    return;
-  }
-  window.scrollTo({ top: 0, behavior: gedrag });
-}
-
-/** De knop verschijnt pas als er iets ONDER je ligt om naar terug te keren. */
-function toonKnop(knop) {
-  knop.classList.toggle('is-zichtbaar', window.scrollY > window.innerHeight * 0.6);
-}
-
-let tapX = 0, tapY = 0;
-function onPointerDown(e) { tapX = e.clientX; tapY = e.clientY; }
-
-function onTap(e) {
-  if (e.defaultPrevented || e.button !== 0) return;
-  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
-  const t = e.target;
-  if (!t || typeof t.closest !== 'function') return;
-  const art = t.closest('.read-post');
-  if (!art) return;
-  if (t.closest('a, button, input, textarea, select, label, summary, [role="button"]')) return;
-  if (Math.abs(e.clientX - tapX) > 10 || Math.abs(e.clientY - tapY) > 10) return;
-  const sel = window.getSelection && window.getSelection();
-  if (sel && String(sel).trim()) return;
-
-  const slug = art.dataset.slug;
-  if (!slug) return;
-  location.href = (art.dataset.base || '') + '/' + encodeURIComponent(slug);
-}
-
-/**
- * Omhoog scrollen blijft VRIJ (Robin, 20-8).
- *
- * Snappen hoort bij doorlezen; ga je terug, dan zoek je iets en bepaal je zelf
- * waar je stopt. CSS kent geen richtingsgevoelig snappen, dus dat ene stukje
- * moet hier -- maar dan ook niet meer dan dat: we zetten de CSS-functie aan of
- * uit op de scroller. Geen vangzones, geen snappunten per bericht, geen
- * scrollpositie-boekhouding.
- *
- * DE RICHTING KOMT UIT DE INVOER, niet uit scroll-events. Gemeten op dev: die
- * events bevatten ook de bewegingen van de browser zelf -- zijn snap-animatie en
- * de rubber-band -- en die gaan soms omhoog. Wie daaruit de richting afleidt,
- * leest de browser en niet de gebruiker.
- *
- * EN ER WORDT ALLEEN GESCHAKELD BIJ STILSTAND. Dat is de tweede les, en die
- * kostte een ronde. Eerst zette dit de schakelaar om bij ELK wiel-event en ELKE
- * vingerbeweging, dus binnen een veeg klapte hij meerdere keren heen en weer.
- * De browser raadpleegt scroll-snap-type alleen aan het eind van een gebaar of
- * van de uitloop, en of je daar net vóór of net ná zit bepaalt dan of er
- * gesnapt wordt. Gemeten (Robins melding "soms triggert het terwijl we nog aan
- * het doorscrollen zijn"):
- *
- *     snappen UIT, beweging naar 1459 loopt
- *     -> halverwege snappen AAN gezet
- *     -> eindigt op 1459, NIET op een berichtgrens
- *
- * Bij stilstand omzetten is wel onschuldig: dezelfde proef gaf 0px sprong.
- * Vandaar: richting bepalen bij het BEGIN van een gebaar, en daarna niets meer
- * aanraken tot de scroll echt stil is.
- *
- * Wat je daarvoor inlevert: binnen een veeg ligt de stand vast. Draai je
- * halverwege om zonder los te laten, dan geldt de stand van dat gebaar nog. Een
- * besluit per gebaar is voorspelbaar; het omklappen halverwege was het probleem.
- */
-const RUST_MS = 120;
-
-let bezig = false;          // loopt er een gebaar of een uitloop?
-let ooitGescrold = false;   // snappen begint UIT, zie zetSnappen()
-let rustTimer = null;
-
-/**
- * LENIS, en alleen op desktop.
- *
- * Op touch doet Lenis van zichzelf niets (syncTouch staat standaard uit) en is
- * het systeem-scrollen al soepel -- daar blijft de native CSS-snap staan die
- * hierboven beschreven is. Robins keuze (20-8): "enkel voor desktop, dat is
- * prima, logisch dat het niet op mobiel kan".
- *
- * WAAROM LENIS HIER STAAT, en dat is niet het vloeiende scrollen: de DUUR van
- * een snap is met native scroll-snap niet in te stellen -- die zit in de browser.
- * Lenis' snap-pakket wel: duration, easing, distanceThreshold en debounce zijn
- * allemaal van ons. Dat was de aanleiding.
- *
- * Het vloeiende scrollen (smoothWheel) kwam er daarna bij, en dat is de kant die
- * OPPASSEN vraagt. Een Mac-trackpad heeft zijn EIGEN momentum, en Lenis'
- * demping komt daar bovenop -- dubbel gedempt voelt drijverig. Vandaar lerp 0.2
- * in plaats van de standaard 0.1. Robin vond dat op 20-8 nog steeds te zweverig,
- * dus dat getal is nog niet uit; zie de opmerking bij de instellingen.
- *
- * De stand met `smoothWheel: false` werkte ook, en dan doet Lenis alleen de
- * snap. Dat is de terugvalpositie als het vloeiende scrollen niet bevalt.
- *
- * lenis/snap haakt alleen in op lenis.on('scroll') en roept lenis.scrollTo aan
- * (nagekeken in de dist), dus die opzet werkt.
- */
-const OP_DESKTOP = window.matchMedia('(hover: hover) and (pointer: fine)');
-// PROEF (20-8): Lenis ook op touch. Robin meldde op mobiel drie dingen -- de
-// header niet zichtbaar bij laden, snappen dat soms te ver gaat, en horizontaal
-// kunnen scrollen. De eerste twee komen van de NATIVE snap: die vangt al bij het
-// laden, en zijn uitloop vliegt bij een flick over een grens heen. Lenis zet de
-// native snap uit en doet het zelf, dus die twee kunnen ermee verdwijnen.
-//
-// De prijs is echt: syncTouch betekent dat Lenis het VINGERSCROLLEN overneemt
-// van iOS -- momentum, rubber-band en het wegschuiven van de adresbalk worden
-// dan een benadering in JavaScript. De makers waarschuwen daar zelf voor op
-// iOS < 16. Dit staat er dus als proef, niet als besluit: voelt het niet goed,
-// dan is OP_TOUCH weer false zetten de hele terugweg.
-const OP_TOUCH = window.matchMedia('(hover: none) and (pointer: coarse)');
-// VENDOR_V komt uit lib.js: een nummer voor de hele vendormap (shaer-724).
-
-let lenis = null;
-let snap = null;
-
-async function startLenis() {
-  // DESKTOP EN TOUCH ALLEBEI. Er heeft hier een tijd gestaan dat mobiel terug was
-  // op de systeemscroll; dat klopte niet meer met de regel hieronder, die
-  // syncTouch juist AANzet op touch. Reden dat Lenis ook op de telefoon meedraait:
-  // zijn snap heeft zijn scroll-gebeurtenissen nodig om te kunnen timen.
-  if (lenis || !(OP_DESKTOP.matches || OP_TOUCH.matches)) return;
-  const [L, S] = await Promise.all([
-    import(`/assets/vendor/lenis.mjs?v=${VENDOR_V}`),
-    import(`/assets/vendor/lenis-snap.mjs?v=${VENDOR_V}`),
-  ]);
-  lenis = new L.default({
-    /**
-     * Wat Lenis MET RUST LAAT.
-     *
-     * In paginamodus staat Lenis stil en scrolt alleen het paneel van het
-     * bericht zelf. Dat regelde ik eerst met een data-lenis-prevent-attribuut
-     * dat de module op elk paneel zette -- maar dan hangt het scrollen af van of
-     * dat attribuut op tijd en op elk (ook later bijgeladen) paneel staat, en
-     * Robin kon binnen een lang bericht niet scrollen.
-     *
-     * Deze functie stelt dezelfde vraag zonder die afhankelijkheid: is dit een
-     * bericht-paneel? Dan bemoeit Lenis zich er niet mee en scrolt de browser
-     * het zelf -- wat op een telefoon precies is wat je wilt, want dat is de
-     * systeemscroll.
-     */
-    prevent: (node) => !!(node && node.classList
-      && node.classList.contains('read-post')
-      && document.body.classList.contains('is-paged')),
-    // Lenis tekent de scrollbeweging zelf, maar STEVIG GEDEMPT (lerp 0.2 in
-    // plaats van de standaard 0.1). Reden: een muiswiel scrollt in schokken en
-    // heeft die demping nodig; een Mac-trackpad heeft zijn EIGEN momentum en
-    // krijgt er dan een tweede overheen -- dat is precies het drijverige gevoel
-    // waar Robin voor waarschuwde. Hoger betekent korter naijlen, dus dit is de
-    // middenweg: de schokjes weg, de nasleep kort.
-    // Staat het toch te zweven, dan is lerp omhoog (richting 1) of terug naar
-    // smoothWheel:false de knop -- die stand werkte ook, met alleen de snap.
-    smoothWheel: true,
-    lerp: 0.2,
-    // Alleen op touch overneemt hij het vingerscrollen; op desktop hoeft dat niet.
-    syncTouch: OP_TOUCH.matches,
-    // Twee touch-knoppen, en ze doen echt iets anders. Lenis rekent bij het
-    // LOSLATEN eenmalig een doel uit -- afstand = snelheid^touchInertiaExponent
-    // -- en kruipt daar dan naartoe met syncTouchLerp.
-    //
-    //   touchInertiaExponent  hoe VER de flick draagt   (standaard 1.7)
-    //   syncTouchLerp         hoe hard hij REMT         (standaard 0.075)
-    //
-    // Let op: `touchInertiaMultiplier` bestaat NIET in 1.3.26. Lenis slikt
-    // onbekende opties zonder fout, dus zo'n regel lijkt te werken en doet niets.
-    // Controleer een optienaam in src/assets/vendor/lenis.mjs voor je hem zet.
-    //
-    // Robin wilde langer en sneller uitrollen zonder remgevoel, dus de exponent
-    // gaat omhoog (een stevige flick draagt daarmee ruim vier tot zes keer zo
-    // ver) en de lerp gaat juist boven de standaard: hij legt die afstand vlot
-    // af in plaats van er stroperig naartoe te kruipen.
-    ...(OP_TOUCH.matches ? { syncTouchLerp: 0.09, touchInertiaExponent: 2.2 } : {}),
-    autoRaf: true,
-  });
-  snap = new S.default(lenis, {
-    type: 'proximity',
-    // DE VANGZONE, en dit is HET getal om aan te draaien. Ooit 12%, toen 55%
-    // (20-8: eerder vangen, boven de reacties), en 20-8 terug naar 12% -- want
-    // sinds het snappen alleen nog vooruit werkt, voelde die brede zone als
-    // trekken tijdens het lezen. 12% is ~110 pixels op een venster van 910: hij
-    // vangt pas vlak vóór de lijn.
-    // Houd dit gelijk aan vangZone() hieronder: deze drempel bepaalt of
-    // lenis/snap überhaupt aanklopt, die andere of wij het doorlaten. Zet je
-    // deze lager dan die, dan komt onze regel nooit aan bod.
-    distanceThreshold: '12%',
-    // Hoe lang na de laatste scrollbeweging hij mag vangen. De standaard is 500
-    // en dat voelt als te laat. Op touch nog korter dan op desktop, want daar
-    // eindigt een veeg in een lange, trage staart -- en juist dan wil je dat de
-    // snap er snel bij is in plaats van te wachten tot de laatste pixel stil ligt.
-    //
-    // LET OP DE SPANNING: een vloeiendere uitloop (lagere syncTouchLerp) maakt
-    // die staart langer, en stelt de snap dus uit. Deze twee getallen houden
-    // elkaar in evenwicht; draai je aan de een, kijk dan ook naar de ander.
-    // OP TOUCH JUIST LANG, en dat is het omgekeerde van wat ik vorige ronde deed.
-    // Tussen je vinger loslaten en het op gang komen van de uitloop zit een korte
-    // stilte in de scrollbeweging. Met 25ms viel de snap precies in dat gat: hij
-    // greep op het moment van loslaten en knipte de veeg af -- wat aanvoelt als
-    // "stroef bij het loslaten", en het werd er dan ook erger van. 200ms laat de
-    // uitloop eerst zijn werk doen.
-    // Met een iOS-achtige uitloop duurt de staart lang; te kort wachten laat de
-    // snap midden in de vlucht ingrijpen. 150ms laat hem uitrollen en vangt dan.
-    debounce: OP_TOUCH.matches ? 150 : 60,
-    // Op touch korter: een telefoon vraagt om directer antwoord dan een muis, en
-    // de snap komt daar aan het eind van een lange uitloop -- dan mag hij kort.
-    duration: OP_TOUCH.matches ? 0.18 : 0.4,
-    // Vlot weg, dan steeds langzamer aankomen (Robin, 20-8). easeOutQuart: op de
-    // helft van de tijd is 94% van de weg af, en de rest dempt zacht uit.
-    // Bewust NIET Lenis' standaard easeOutExpo -- die schiet weg en kruipt dan
-    // zo lang na dat het lijkt of hij niet afmaakt.
-    easing: (t) => 1 - Math.pow(1 - t, 4),
-  });
-  // Het snappunt is de bovenkant van elk bericht -- ook van het laatste. Dat
-  // deed eerder niet mee omdat zijn bovenkant onbereikbaar was; sinds het
-  // laatste bericht in CSS een volle schermhoogte krijgt, kan hij wel.
-  const berichten = [...document.querySelectorAll('.feed-reader .read-post')];
-  berichten.forEach((a) => snap.addElement(a, { align: 'start' }));
-
-  // ALLEEN VOORUIT VANGEN. Dit hoort bij de ruime zone hierboven en is niet
-  // optioneel: lenis/snap kiest het DICHTSTBIJZIJNDE punt en zijn drempel geldt
-  // naar twee kanten. Scroll je een lang bericht in en stop je 300 pixels onder
-  // de bovenkant, dan is die bovenkant het dichtstbij -- en met een zone van 400
-  // wordt je teruggetrokken. Dat is precies de klacht "ik kan niet scrollen
-  // binnen een lang bericht", en met de oude zone van 86 pixels viel het alleen
-  // niet op.
-  //
-  // Dus: een doel ACHTER je slaan we over. Ligt er een punt voor je binnen de
-  // zone, dan gaan we daarheen; anders gebeurt er niets en scrol je vrij door.
-  // Zo verruimt de zone alleen de kant waar hij bedoeld is.
-  //
-  // Waarom niet gewoon de drempel? Omdat lenis/snap alleen de dichtstbijzijnde
-  // kandidaat beoordeelt: zonder deze omleiding houdt de zone vooruit op bij de
-  // helft van de afstand tussen twee berichten, hoe groot je de drempel ook zet.
-  //
-  // DE REGEL, in Robins woorden (20-8): "enkel bij downscrollen, aan de
-  // onderkant van elke post, snappen naar de lijn tussen de posts" -- en geen
-  // snap op een bericht dat al voorbij is gescrold. Drie voorwaarden dus:
-  //   1. de laatste echte beweging ging omlaag,
-  //   2. het doel ligt VOOR je (een punt achter je slaan we over),
-  //   3. het ligt binnen de vangzone -- die begint rond de voet van het bericht.
-  // Het doel zelf blijft de bovenkant van het volgende bericht: dat IS de lijn
-  // ertussen, en dat is het essentiële.
-  //
-  // DE ZONE IS EEN GETAL, geen '55%'. Die string kwam ongewijzigd uit de opties
-  // en werd hier met een getal vergeleken -- altijd onwaar, dus deze terugval
-  // heeft nooit gewerkt en alleen het doel dat lenis zelf koos kwam erdoor.
-  const vangZone = () => 0.12 * window.innerHeight;   // gelijk aan distanceThreshold hierboven
-  // Lenis' eigen `direction` is het teken van de snelheid, en die is bij het
-  // afvuren van de (gedebouncede) snap alweer nul. Daarom onthouden we de
-  // laatste richting die er echt was.
-  let laatsteRichtingOmlaag = true;
-  lenis.on('scroll', () => {
-    if (Math.abs(lenis.velocity) > 0.05) laatsteRichtingOmlaag = lenis.velocity > 0;
-  });
-  const echtGaNaar = snap.goTo.bind(snap);
-  snap.goTo = (index) => {
-    if (!laatsteRichtingOmlaag) return;   // omhoog: nooit vangen
-    const punten = snap.computeSnaps();   // zelfde volgorde als goTo intern gebruikt
-    const nu = lenis.scroll;
-    let vooruit = -1;
-    punten.forEach((punt, i) => {
-      if (punt.value > nu + 2 && (vooruit < 0 || punt.value < punten[vooruit].value)) vooruit = i;
-    });
-    if (vooruit < 0) return;              // niets meer voor je: vrij uitscrollen
-    if (punten[vooruit].value - nu <= vangZone()) echtGaNaar(vooruit);
-  };
-
-  // OP TOUCH DOET DE MODULE-EIGEN TRIGGER HET NIET, en dat is meetbaar in de
-  // vendor-bron, geen vermoeden. Drie feiten op een rij:
-  //
-  //   1. lenis geeft 'virtual-scroll' door met de RUWE vingerdelta, VOOR hij
-  //      de uitloop uitrekent (teken * |snelheid|^touchInertiaExponent).
-  //   2. lenis-snap negeert elke touchmove en beoordeelt dus EEN keer per
-  //      gebaar, op touchend, met `scroll + rawDelta` -- de plek waar je
-  //      vinger LOSLIET.
-  //   3. de uitloop draagt daarna nog honderden pixels verder.
-  //
-  // De snapbeslissing valt dus op een positie die de scroll meteen verlaat.
-  // Op desktop klopt dezelfde som wel: wieldelta's zijn klein en de
-  // smoothWheel-uitloop is kort, dus voorspelling en landing liggen bijeen.
-  // Vandaar: op touch de module-trigger eraf en zelf beoordelen tegen
-  // lenis.targetScroll -- die wordt synchroon bij touchend gezet en IS het
-  // exacte landingspunt van de uitloop.
-  if (OP_TOUCH.matches) {
-    // Hoe lang het richten naar de lijn duurt, in seconden. De ene knop voor
-    // "snapperiger": 0.18 is direct, 0.3 is landen, 0.5 is zweven.
-    const SNAP_DUUR = 0.22;
-    lenis.off('virtual-scroll', snap.onSnapDebounced);
-    let raakTimer = null;
-    let vertrek = 0;   // scrollpositie bij loslaten: punten daarachter zijn
-                       // de bovenkant van het HUIDIGE bericht, nooit grijpen
-    lenis.on('virtual-scroll', (e) => {
-      const soort = e.event && e.event.type;
-      if (soort === 'touchstart' || soort === 'touchmove') { clearTimeout(raakTimer); return; }
-      if (soort !== 'touchend') return;
-      vertrek = lenis.scroll;
-      clearTimeout(raakTimer);
-      // TIMEOUT 0, geen 150ms. Dit event komt binnen VOOR lenis zelf de
-      // touchend verwerkt (de emit staat in de bron boven de verwerking), dus
-      // targetScroll is hier nog het oude doel. Een tik later is de uitloop
-      // gestart en staat targetScroll op de echte landing -- en dan sturen we
-      // hem meteen bij, in plaats van de uitloop 150ms te laten lopen en er
-      // dan een aparte snap-animatie overheen te zetten.
-      raakTimer = setTimeout(() => {
-        if (!laatsteRichtingOmlaag || snap.isStopped) return;
-        const doel = lenis.targetScroll;   // de echte landing, niet de raakdelta
-        const punten = snap.computeSnaps();
-        // De EERSTE grens voorbij het loslaatpunt, niet de dichtstbijzijnde bij
-        // de landing. Robins regel (24-8): wordt het target voorbijgescrolld,
-        // dan landen we alsnog OP het target. De zone werkt dus alleen aan de
-        // korte kant -- kom je er niet eens bij in de buurt, dan lees je gewoon
-        // binnen een lang bericht en blijft alles vrij. Eroverheen, hoe ver
-        // ook, betekent grijpen. Een harde veeg komt daarmee altijd precies
-        // een bericht verder, nooit twee: dat is het bladeren.
-        let eerste = -1;
-        punten.forEach((punt, i) => {
-          if (punt.value > vertrek + 2 && (eerste < 0 || punt.value < punten[eerste].value)) eerste = i;
-        });
-        if (eerste < 0) return;            // niets meer voor je: vrij uitscrollen
-        // HET RICHTEN IS DE SNAP (Robins regel, 24-8): de lopende uitloop
-        // krijgt het nieuwe doel, er komt geen tweede animatie overheen.
-        //
-        // MET EEN VASTE DUUR, niet met de lerp van de uitloop. Die lerp-vorm
-        // (0.09) is boterzacht maar heeft een exponentiele staart van ruim een
-        // halve seconde -- Robin wilde het korter en beslister (24-8). Een
-        // duur eindigt ECHT, op een instelbaar moment, zoals iOS-paging: vlot
-        // weg, stevig landen. Zelfde easing-familie als de desktop-snap.
-        // SNAP_DUUR is de enige knop: korter is snapperiger, langer is zachter.
-        if (doel >= punten[eerste].value - vangZone()) {
-          lenis.scrollTo(punten[eerste].value, {
-            duration: SNAP_DUUR,
-            easing: (t) => 1 - Math.pow(1 - t, 4),
-            force: true,
-            userData: { initiator: 'snap' },
-          });
-        }
-      }, 0);
-    });
-  }
-
-  // Native snappen uit: twee mechanismen op dezelfde scroller vechten.
-  document.documentElement.style.scrollSnapType = 'none';
-}
-
-function stopLenis() {
-  if (snap) { snap.destroy(); snap = null; }
-  if (lenis) { lenis.destroy(); lenis = null; }
-  document.documentElement.style.scrollSnapType = '';
-}
-
-/**
- * Omhoog niet snappen. Met Lenis is dat snap.stop()/start(); zonder Lenis (dus
- * op touch) zetten we de CSS-eigenschap om, precies zoals hiervoor.
- */
-function zetSnappen(aan) {
-  if (snap) { if (aan) snap.start(); else snap.stop(); return; }
-  // Zonder Lenis (mobiel) zetten we de CSS-eigenschap om. Hij staat bij het
-  // laden UIT -- dat is de standaardstand van deze variabele -- zodat de browser
-  // niet meteen naar het eerste bericht springt en de header wegvalt. Pas je
-  // eerste gebaar naar beneden zet hem aan.
-  const el = document.documentElement;
-  const wil = aan ? '' : 'none';
-  if (el.style.scrollSnapType !== wil) el.style.scrollSnapType = wil;
-}
-
-/** Een richting geldt alleen als er NIETS beweegt. Anders negeren we hem. */
-function nieuwGebaar(naarBeneden) {
-  if (bezig) return;
-  bezig = true;
-  zetSnappen(naarBeneden);
-}
-
-/**
- * Het gebaar is pas voorbij als de SCROLL stil is, niet als de vinger loslaat:
- * op iOS loopt de uitloop daarna nog door. `scrollend` zegt dat precies, maar
- * bestaat niet overal (Chrome 114+, Safari 17+) -- vandaar ook de timer.
- */
-function rustNu() { bezig = false; }
-function planRust() {
-  clearTimeout(rustTimer);
-  rustTimer = setTimeout(rustNu, RUST_MS);
-}
-
-function opWiel(e) { if (Math.abs(e.deltaY) > 1) nieuwGebaar(e.deltaY > 0); }
-let raakY = 0;
-function opRaakStart(e) { if (e.touches && e.touches[0]) raakY = e.touches[0].clientY; }
-function opRaakBeweeg(e) {
-  if (!e.touches || !e.touches[0]) return;
-  const y = e.touches[0].clientY;
-  // Vinger omhoog = inhoud omlaag. Drie pixels speling tegen de trilling van een
-  // duim die stilstaat.
-  if (Math.abs(y - raakY) > 3) { nieuwGebaar(y < raakY); raakY = y; }
-}
-function opToets(e) {
-  if (['ArrowDown', 'PageDown', 'End', ' ', 'Spacebar'].indexOf(e.key) >= 0) nieuwGebaar(true);
-  else if (['ArrowUp', 'PageUp', 'Home'].indexOf(e.key) >= 0) nieuwGebaar(false);
-}
-
-let knop = null;
-let opScroll = null;
-
-export function init() {
-  // EERST OPRUIMEN, en pas daarna terugvallen als er geen leesstroom is.
-  //
-  // Dit stond andersom, en dat brak het scrollen op de HELE site: navigeerde je
-  // van Lezen naar het beheer, dan viel init() bij de ontbrekende leesstroom
-  // meteen terug -- en bleef Lenis leven EN gestopt (lenis.stop() vangt wiel en
-  // vinger af). Daarna scrolde niets meer, ook niet op pagina's die met Lezen
-  // niets te maken hebben. Robins melding (20-8): "scrollen werkt nu nergens ook
-  // niet in admin panels".
-  //
-  // Opruimen hoort dus bij het VERLATEN van de weergave, niet bij het opzetten
-  // ervan. init() draait bij elke paginawissel, dus dit is de plek.
-  stopLenis();
-  document.body.classList.remove('is-paged');
-  document.querySelectorAll('[data-lenis-prevent].read-post')
-    .forEach((a) => a.removeAttribute('data-lenis-prevent'));
-
-  const s = document.getElementById('read-stream');
-  if (!s) return;
-  // Op de stroom, niet per artikel: wat "meer laden" erbij zet doet vanzelf mee.
-  // init() draait bij ELKE paginawissel, dus eerst losmaken -- anders stapelt
-  // dezelfde afhandelaar zich op en vuurt hij twee keer.
-  s.removeEventListener('pointerdown', onPointerDown);
-  s.removeEventListener('click', onTap);
-  s.addEventListener('pointerdown', onPointerDown, { passive: true });
-  s.addEventListener('click', onTap);
-
-  // De knop staat in de HTML, zodat hij er ook is zonder deze module -- dan doet
-  // hij niets, maar hij springt niet in beeld bij het laden.
-  // De terug-naar-boven-knop staat er tijdelijk uit (Robin, 20-8) -- in
-  // paginamodus doet de bovenbalk dat werk al. De code blijft staan zodat hij
-  // met een regel terug is.
-  knop = null;
-  const oudeKnop = document.getElementById('read-top');
-  if (oudeKnop) oudeKnop.hidden = true;
-
-  if (opScroll) {
-    window.removeEventListener('scroll', opScroll);
-    window.removeEventListener('scrollend', rustNu);
-    window.removeEventListener('resize', opScroll);
-    window.removeEventListener('wheel', opWiel);
-    window.removeEventListener('touchstart', opRaakStart);
-    window.removeEventListener('touchmove', opRaakBeweeg);
-    window.removeEventListener('keydown', opToets);
-  }
-  // Elke scroll -- van een vinger of van de browser zelf -- houdt het gebaar
-  // levend; pas als het stil blijft mag een nieuwe richting gelden.
-  opScroll = () => {
-    if (knop) toonKnop(knop);
-    planRust();
-    if (document.body.classList.contains('is-paged')) zetBalken();
-  };
-  window.addEventListener('scroll', opScroll, { passive: true });
-  window.addEventListener('scrollend', rustNu);
-  window.addEventListener('resize', opScroll, { passive: true });
-  window.addEventListener('wheel', opWiel, { passive: true });
-  window.addEventListener('touchstart', opRaakStart, { passive: true });
-  window.addEventListener('touchmove', opRaakBeweeg, { passive: true });
-  window.addEventListener('keydown', opToets);
-
-  const balkBoven = document.getElementById('read-prev');
-  const balkOnder = document.getElementById('read-next-nav');
-  if (balkBoven) balkBoven.onclick = gaOmhoog;
-  if (balkOnder) balkOnder.onclick = () => gaNaar(huidigIndex() + 1);
-
-  // Alleen in de leesweergave, en alleen op desktop. Bij elke init() opnieuw
-  // beoordelen: van Grid naar Lezen schakelen hoort hem aan te zetten, en
-  // wegnavigeren hoort hem op te ruimen.
-  if (document.body.dataset.feedView === 'reader') startLenis().then(pasPaginaModusToe);
-  else pasPaginaModusToe();
-
-
-}
-
-export default { init };
Index: src/assets/js/mod/reply-editor.js
===================================================================
--- src/assets/js/mod/reply-editor.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,225 +1,0 @@
-// Rijke reply-editor -- MODULE sinds shaer-nh2.
-//
-// Stond als <script src> onderaan partials/reply-editor.ejs. Bij een volledige
-// laadbeurt ging dat goed (injectCspNonce zet er een nonce op), maar bij een
-// htmx-navigatie draagt de partial de nonce van DAT verzoek terwijl het
-// document die van zijn eigen laadbeurt heeft. De CSP is strict-dynamic, dus
-// alleen die laatste telt -- en het script draaide niet. Gevolg: geen toolbar,
-// geen .re-full, en op mobiel dus geen fullscreen-compose.
-//
-// Als module wordt hij door de bootstrap in de shell geladen, die WEL de juiste
-// nonce heeft. Dat is bovendien de voorwaarde om straks code te DELEN met de
-// posteditor: een los script kan niet importeren uit mod/lib.js.
-
-// Rich reply editor — upgrades every form[data-re] (partials/reply-editor.ejs)
-// from a plain textarea to a contenteditable with a small toolbar and, on
-// narrow screens (≤700px), a full-screen compose overlay (the right pattern on
-// mobile). Progressive enhancement: without this file the textarea submits as
-// before. On submit: `content` = editor HTML (server sanitizes), `text` =
-// plain-text fallback.
-
-  var MOBILE = '(max-width: 700px)';
-
-function initForm(form) {
-    if (form.__re) return;
-    form.__re = true;
-
-    var ta = form.querySelector('textarea[name="text"]');
-    var ed = form.querySelector('.re-editor');
-    var bar = form.querySelector('.re-toolbar');
-    var head = form.querySelector('.re-head');
-    var foot = form.querySelector('.re-foot');
-    var lang = form.querySelector('.re-lang');
-    var hidden = form.querySelector('input[name="content"]');
-    if (!ta || !ed || !hidden) return;
-
-    // Upgrade: swap the textarea for the editor.
-    ta.hidden = true;
-    ta.required = false;
-    ed.hidden = false;
-    bar.hidden = false;
-    if (lang) lang.hidden = false;
-
-    // ── Media attachments: picker (📎), drag/drop and paste ──────────────
-    var attWrap = form.querySelector('.re-attachments');
-    var attField = form.querySelector('input[name="attachments"]');
-    var fileInput = form.querySelector('.re-file');
-    var canAttach = !!(attWrap && attField && fileInput);   // edit mode renders without media
-    var attachments = [];
-
-    function syncAtt() {
-      if (!canAttach) return;
-      attField.value = attachments.length ? JSON.stringify(attachments) : '';
-      attWrap.hidden = attachments.length === 0;
-    }
-    function addChip(a) {
-      var chip = document.createElement('span');
-      chip.className = 're-att';
-      if (a.mediaType.indexOf('image/') === 0) {
-        var img = document.createElement('img');
-        img.src = a.url; img.alt = a.name || '';
-        chip.appendChild(img);
-      } else {
-        chip.appendChild(document.createTextNode((a.mediaType.indexOf('audio/') === 0 ? '🎵 ' : '🎬 ') + (a.name || a.mediaType)));
-      }
-      var del = document.createElement('button');
-      del.type = 'button'; del.className = 're-att-del'; del.textContent = '×';
-      del.addEventListener('click', function () {
-        attachments = attachments.filter(function (x) { return x !== a; });
-        chip.remove(); syncAtt();
-      });
-      chip.appendChild(del);
-      attWrap.appendChild(chip);
-    }
-    function uploadFiles(files) {
-      if (!canAttach) return;
-      Array.prototype.forEach.call(files, function (file) {
-        if (!/^(image|audio|video)\//.test(file.type) || attachments.length >= 4) return;
-        var chip = document.createElement('span');
-        chip.className = 're-att re-att-busy';
-        chip.textContent = '⏳ ' + file.name;
-        attWrap.hidden = false;
-        attWrap.appendChild(chip);
-        var fd = new FormData();
-        fd.append('media', file);
-        fetch(form.getAttribute('data-upload'), { method: 'POST', body: fd })
-          .then(function (r) { return r.json().then(function (j) { return r.ok ? j : Promise.reject(j); }); })
-          .then(function (j) {
-            chip.remove();
-            var a = { url: j.url, mediaType: j.mediaType, name: j.name || file.name };
-            attachments.push(a); addChip(a); syncAtt();
-          })
-          .catch(function (err) {
-            chip.className = 're-att re-att-err';
-            chip.textContent = (form.getAttribute('data-upload-err') || 'Upload failed') + (err && err.error ? ': ' + err.error : '');
-            setTimeout(function () { chip.remove(); syncAtt(); }, 5000);
-          });
-      });
-    }
-
-    // Toolbar commands (execCommand is deprecated-but-universal; same approach
-    // as the post editor).
-    bar.addEventListener('click', function (e) {
-      var btn = e.target.closest('button[data-cmd]');
-      if (!btn) return;
-      e.preventDefault();
-      var cmd = btn.getAttribute('data-cmd');
-      if (cmd === 'attach') { if (fileInput) fileInput.click(); return; }   // no editor focus: keeps the picker usable on mobile
-      ed.focus();
-      if (cmd === 'bold') document.execCommand('bold');
-      else if (cmd === 'italic') document.execCommand('italic');
-      else if (cmd === 'list') document.execCommand('insertUnorderedList');
-      else if (cmd === 'quote') document.execCommand('formatBlock', false, 'blockquote');
-      else if (cmd === 'link') {
-        var url = window.prompt(form.getAttribute('data-link-prompt') || 'URL');
-        if (url && /^https?:\/\//i.test(url.trim())) document.execCommand('createLink', false, url.trim());
-      }
-    });
-
-    // Reflect the current formatting on the toolbar (bold/italic/list active),
-    // like the post editor, while the caret is inside this editor.
-    function syncToolbar() {
-      if (document.activeElement !== ed) return;
-      var map = { bold: 'bold', italic: 'italic', list: 'insertUnorderedList' };
-      bar.querySelectorAll('button[data-cmd]').forEach(function (b) {
-        var c = map[b.getAttribute('data-cmd')];
-        if (!c) return;
-        var on = false; try { on = document.queryCommandState(c); } catch (e) { on = false; }
-        b.classList.toggle('is-active', on);
-      });
-    }
-    document.addEventListener('selectionchange', syncToolbar);
-    ed.addEventListener('keyup', syncToolbar);
-    ed.addEventListener('mouseup', syncToolbar);
-
-    if (fileInput) fileInput.addEventListener('change', function () {
-      uploadFiles(fileInput.files);
-      fileInput.value = '';
-    });
-
-    // Paste: files become attachments; text pastes as plain text (rich paste
-    // becomes messy HTML; formatting is what the toolbar is for).
-    ed.addEventListener('paste', function (e) {
-      var cd = e.clipboardData || window.clipboardData;
-      if (cd.files && cd.files.length) {
-        e.preventDefault();            // never let the browser inline-paste a file as base64
-        uploadFiles(cd.files);         // no-op without the attach UI (edit mode)
-        return;
-      }
-      var txt = cd.getData('text/plain');
-      if (!txt) return;
-      e.preventDefault();
-      document.execCommand('insertText', false, txt);
-    });
-
-    // Drag/drop media onto the editor.
-    if (canAttach) {
-    ed.addEventListener('dragover', function (e) {
-      if (e.dataTransfer && Array.prototype.some.call(e.dataTransfer.types || [], function (t) { return t === 'Files'; })) {
-        e.preventDefault();
-        ed.classList.add('re-drop');
-      }
-    });
-    ed.addEventListener('dragleave', function () { ed.classList.remove('re-drop'); });
-    ed.addEventListener('drop', function (e) {
-      ed.classList.remove('re-drop');
-      if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
-        e.preventDefault();
-        uploadFiles(e.dataTransfer.files);
-      }
-    });
-    }
-
-    // Mentions bar (u02): removing a chip stops addressing that partner. The
-    // hidden field always mirrors the remaining chips.
-    var mField = form.querySelector('input[name="mentions"]');
-    if (mField) {
-      var parts = [];
-      try { parts = JSON.parse(mField.value || '[]'); } catch (e) { parts = []; }
-      form.querySelectorAll('.re-mention-del').forEach(function (btn) {
-        btn.addEventListener('click', function () {
-          var chip = btn.closest('.re-mention');
-          var uri = chip.getAttribute('data-uri');
-          parts = parts.filter(function (p) { return p.uri !== uri; });
-          mField.value = JSON.stringify(parts);
-          chip.remove();
-        });
-      });
-    }
-
-    // Full-screen compose on mobile: enter on focus, leave via ×.
-    function setFull(on) {
-      form.classList.toggle('re-full', on);
-      head.hidden = !on;
-      document.documentElement.classList.toggle('re-lock', on);
-      if (on) ed.focus();
-    }
-    ed.addEventListener('focus', function () {
-      if (window.matchMedia(MOBILE).matches && !form.classList.contains('re-full')) setFull(true);
-    });
-    var cancel = form.querySelector('.re-cancel');
-    if (cancel) cancel.addEventListener('click', function () { setFull(false); });
-
-    // Serialize on submit; block truly empty replies (media-only is fine).
-    form.addEventListener('submit', function (e) {
-      var html = ed.innerHTML.trim();
-      var plain = (ed.innerText || '').replace(/ /g, ' ').trim();
-      if (!plain && !attachments.length) { e.preventDefault(); ed.focus(); return; }
-      hidden.value = plain ? html : '';
-      ta.value = plain;
-      document.documentElement.classList.remove('re-lock');
-    });
-
-    // Open <details> parents (fedi-node) keep working: nothing special needed.
-    void foot;
-  }
-
-/**
- * De bootstrap roept dit aan bij elke paginawissel waarop deze module actief
- * is. Opnieuw scannen is veilig: init(form) heeft een eigen `__re`-vlag per
- * FORMULIER, en na een htmx-wissel zijn de formulieren nieuw -- dus geen vlag,
- * dus opnieuw opgewaardeerd. Precies wat je wilt.
- */
-export function init() {
-  document.querySelectorAll('form[data-re]').forEach(initForm);
-}
Index: src/assets/js/mod/tape.js
===================================================================
--- src/assets/js/mod/tape.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,197 +1,0 @@
-/**
- * Het bandje: een cassette die draait, en alleen vooruit en achteruit speelt.
- *
- * WAT DEZE MODULE WEL EN NIET DOET. Hij speelt niets af. De site heeft al een
- * speler (audio-player.js) met een wachtrij, een mini-speler onderin en een
- * sessie die een paginawissel overleeft. Een tweede speler ernaast bouwen om
- * een bandgevoel na te doen zou twee dingen tegelijk laten klinken en die hele
- * sessie weggooien. Dus: de afspeelknop draagt dezelfde data-attributen als een
- * albumhoes en wordt door die speler opgepakt.
- *
- * SPOELEN GAAT IN SECONDEN, over de nummergrenzen heen. Dat kan omdat die
- * speler een wachtrij in EEN doorlopende MediaSource-tijdlijn giet: "een
- * trackwissel is een positie, geen omschakeling". Een bandje is dus letterlijk
- * die tijdlijn. Vandaar seekBy() en niet next()/prev() -- springen per nummer
- * is een playlistgebaar, en dan is dit een lijst met een cassetteplaatje.
- *
- * DE SPOELLUS STAAT IN DE SPELER, niet hier: sinds de speler onderin dezelfde
- * spoelknoppen heeft zou dezelfde versnelling op twee plekken staan. Deze
- * module bedient hem en tekent het jasje.
- *
- * WAAROM ER GEPOLLD WORDT. audio-player.js zendt geen gebeurtenissen uit -- het
- * is een gesloten module met een klein oppervlak (setQueue, play, pause, next,
- * prev, isPlaying, currentTrack). Liever hier vijf keer per seconde kijken dan
- * daar een gebeurtenissenlaag inbouwen die de rest van de site niet vraagt. Het
- * polsen stopt zodra er geen bandje op de pagina staat.
- */
-
-const TIK_MS = 200;
-let tikker = null;
-let gebonden = null;   // de container waarop de klikafhandelaar hangt
-
-/** De speler, of niets als hij (nog) niet geladen is. */
-const speler = () => window.pcmsAudioPlayer || null;
-
-/** De url's die op dit bandje staan, in volgorde. */
-function bandUrls(tape) {
-  try {
-    return JSON.parse(tape.dataset.pcmsAlbum || '[]').map((t) => t.url);
-  } catch { return []; }
-}
-
-/**
- * Speelt DIT bandje op dit moment? Vergelijken op url en niet op index: de
- * wachtrij kan ondertussen van een ander blok komen, en dan hoort deze cassette
- * gewoon stil te staan.
- */
-function huidigeIndex(tape) {
-  const p = speler();
-  if (!p) return -1;
-  const nu = p.currentTrack();
-  if (!nu || !nu.url) return -1;
-  return bandUrls(tape).indexOf(nu.url);
-}
-
-function tekenBand(tape) {
-  const i = huidigeIndex(tape);
-  const p = speler();
-  const draait = i >= 0 && !!p && p.isPlaying();
-
-  tape.classList.toggle('is-playing', draait);
-  tape.classList.toggle('is-loaded', i >= 0);
-  // Spoelt de SPELER (bijvoorbeeld via zijn eigen knoppen onderin), dan hoort
-  // deze cassette dat ook te laten zien.
-  const windt = i >= 0 && !!p && p.isWinding && p.isWinding();
-  if (!windt) tape.classList.remove('is-winding', 'is-winding-back');
-  else tape.classList.add('is-winding');
-
-  const knop = tape.querySelector('[data-tape-play]');
-  if (knop) {
-    knop.classList.toggle('is-pauze', draait);
-    knop.setAttribute('aria-label', draait ? 'Pauzeren' : 'Afspelen');
-  }
-
-  // Welk nummer loopt er. Bij een bandje is dat de enige plek waar je het leest,
-  // want de lijst is met opzet geen knoppenrij.
-  const items = [...tape.querySelectorAll('.tape-track')];
-  items.forEach((li, n) => li.classList.toggle('is-current', n === i));
-  const nu = tape.querySelector('[data-tape-now]');
-  if (nu) {
-    const titel = i >= 0 && items[i] ? items[i].querySelector('.tape-track-title').textContent : '';
-    nu.textContent = i >= 0 ? `${i + 1}. ${titel}` : '';
-  }
-}
-
-function tekenAlles() {
-  for (const tape of document.querySelectorAll('.post-tape')) tekenBand(tape);
-}
-
-/**
- * SPOELEN IS VASTHOUDEN, en dat is het verschil met een afspeellijst.
- *
- * Eerst deed vooruit en achteruit hier next()/prev(): een nummer verder. Dat is
- * een playlistgebaar. Op een cassette bestaat "volgend nummer" niet -- je houdt
- * de knop ingedrukt, de band loopt door, en je laat los waar je bent. Robin
- * wees daarop (21-8) en hij heeft gelijk: met knoppen die per nummer springen
- * is het een lijst met een cassetteplaatje erboven.
- *
- * Het versnelt terwijl je hem vasthoudt, zoals een echt bandje op gang komt.
- * Een korte TIK spoelt een klein stukje -- dat is er voor toetsenbord en
- * schermlezer, want vasthouden is met een spatiebalk geen gebaar.
- */
-const TIK_STAP_S = 5;        // een losse klik
-
-function stopWinden(tape) {
-  const p = speler();
-  if (p && p.stopWind) p.stopWind();
-  if (tape) tape.classList.remove('is-winding', 'is-winding-back');
-}
-
-function startWinden(tape, richting) {
-  const p = speler();
-  if (!p || !p.startWind || huidigeIndex(tape) < 0) return;
-  // DE LUS STAAT IN DE SPELER. Hij stond hier, en toen kreeg de speler zelf ook
-  // spoelknoppen -- twee kopieen van dezelfde versnelling die uit elkaar lopen
-  // zodra iemand aan een getal draait. Deze module zet alleen nog het jasje:
-  // welke spoelen draaien en hoe hard.
-  p.startWind(richting);
-  tape.classList.add('is-winding');
-  if (richting < 0) tape.classList.add('is-winding-back');
-}
-
-function opPointerDown(e) {
-  const knop = e.target.closest && e.target.closest('[data-tape-go]');
-  if (!knop) return;
-  const tape = knop.closest('.post-tape');
-  if (!tape) return;
-  startWinden(tape, knop.dataset.tapeGo === 'fwd' ? 1 : -1);
-}
-
-function opPointerUp() {
-  if (winder) stopWinden(winder.tape);
-}
-
-function opKlik(e) {
-  const tape = e.target.closest && e.target.closest('.post-tape');
-  if (!tape) return;
-  const p = speler();
-
-  const spoel = e.target.closest('[data-tape-go]');
-  if (spoel) {
-    // Een tik spoelt een stukje. Vasthouden gaat via de pointer-afhandelaars
-    // hierboven; die hebben dan al gespoeld en deze klik doet er nog een klein
-    // beetje bovenop, wat niet stoort.
-    if (!p || huidigeIndex(tape) < 0) return;
-    p.seekBy(spoel.dataset.tapeGo === 'fwd' ? TIK_STAP_S : -TIK_STAP_S);
-    setTimeout(() => tekenBand(tape), 60);
-    return;
-  }
-
-  const play = e.target.closest('[data-tape-play]');
-  if (play && p) {
-    // Loopt dit bandje al, dan is deze knop een pauzeknop. De globale
-    // afhandelaar van audio-player.js zou hem anders opnieuw vanaf het begin
-    // starten, want die kent alleen "speel deze wachtrij".
-    const i = huidigeIndex(tape);
-    if (i >= 0) {
-      e.preventDefault();
-      e.stopPropagation();
-      if (p.isPlaying()) p.pause(); else p.play();
-      setTimeout(tekenAlles, 60);
-    }
-    // Staat het bandje stil, dan laten we de klik doorlopen: de speler pakt de
-    // data-attributen op en zet de band op.
-  }
-}
-
-export function init() {
-  const tapes = document.querySelectorAll('.post-tape');
-
-  // Altijd eerst opruimen, ook als er niets staat. Zonder dat blijft de tikker
-  // lopen na een paginawissel naar een pagina zonder bandje -- dezelfde fout
-  // die in de leesweergave een keer de hele site heeft vastgezet.
-  if (tikker) { clearInterval(tikker); tikker = null; }
-  stopWinden(null);
-  if (gebonden) {
-    gebonden.removeEventListener('click', opKlik, true);
-    gebonden.removeEventListener('pointerdown', opPointerDown, true);
-    gebonden.removeEventListener('pointerup', opPointerUp, true);
-    gebonden.removeEventListener('pointercancel', opPointerUp, true);
-    gebonden = null;
-  }
-  if (!tapes.length) return;
-
-  // Vangen in de CAPTURE-fase, want de speler luistert zelf op document in de
-  // bubbelfase. Alleen zo kan de pauzeknop zijn klik tegenhouden voordat de
-  // wachtrij opnieuw wordt gezet.
-  gebonden = document;
-  gebonden.addEventListener('click', opKlik, true);
-  gebonden.addEventListener('pointerdown', opPointerDown, true);
-  // Loslaten telt ook als je BUITEN de knop loslaat, anders blijft de band
-  // doorspoelen als je met je vinger wegglijdt.
-  gebonden.addEventListener('pointerup', opPointerUp, true);
-  gebonden.addEventListener('pointercancel', opPointerUp, true);
-
-  tikker = setInterval(tekenAlles, TIK_MS);
-  tekenAlles();
-}
Index: src/assets/js/mod/track-editor.js
===================================================================
--- src/assets/js/mod/track-editor.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,482 +1,0 @@
-// De track-editor (beheer > audio) (partials/track-editor.ejs) -- verplaatst uit inline script, shaer-bqr.
-//
-// Inline script in een pagina wordt door de CSP geweigerd zodra je die pagina via
-// een link BINNEN de site opent: de nonce rouleert per verzoek (shaer-0i6). Dit
-// bestand wordt door de bootstrap in shell.ejs geladen en heeft dat probleem niet.
-//
-// Alles hier hoort GEDELEGEERD te luisteren (op document, niet op een element dat
-// er nu staat) en tegen een tweede aanroep te kunnen.
-
-(function() {
-
-  function esc(s) {
-    return String(s == null ? '' : s)
-      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
-      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
-  }
-
-  async function api(method, url, body) {
-    const opts = { method, credentials: 'same-origin', headers: {} };
-    if (body !== undefined) {
-      if (body instanceof FormData) {
-        opts.body = body;
-      } else {
-        opts.headers['Content-Type'] = 'application/json';
-        opts.body = JSON.stringify(body);
-      }
-    }
-    const res = await fetch(url, opts);
-    let data;
-    try { data = await res.json(); }
-    catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); }
-    if (!res.ok) data.ok = false;
-    return data;
-  }
-
-  /**
-   * Open the track editor.
-   * @param {object} opts
-   * @param {string} opts.id        — track id to edit
-   * @param {function?} opts.onSaved — called with updated track on success
-   */
-  window.openTrackEditor = async function openTrackEditor({ id, onSaved }) {
-    if (!id) return;
-    let track, albumSuggestions = [];
-    try {
-      const [trackJson, listJson] = await Promise.all([
-        api('GET', '/admin/audio/api/' + encodeURIComponent(id)),
-        api('GET', '/admin/audio/api/albums'),
-      ]);
-      if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden');
-      track = trackJson.track;
-      if (listJson.ok && Array.isArray(listJson.albums)) {
-        albumSuggestions = listJson.albums.filter(Boolean);
-      }
-    } catch (err) {
-      alert('Track ophalen mislukt: ' + err.message);
-      return;
-    }
-
-    const backdrop = document.createElement('div');
-    backdrop.className = 'te-backdrop';
-    backdrop.innerHTML = `
-      <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken">
-        <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div>
-
-        <div class="te-header">
-          <h3>✎ Track bewerken</h3>
-          <button type="button" class="te-close" aria-label="Sluiten">×</button>
-        </div>
-
-        <div class="te-body">
-
-          ${track.stream_url ? `
-          <div class="te-preview">
-            <div class="te-preview-cover">
-              ${track.cover_url
-                ? `<img src="${esc(track.cover_url)}" alt="">`
-                : `🎵`}
-            </div>
-            <div class="te-preview-meta">
-              <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div>
-              <div class="te-preview-sub">
-                ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''}
-              </div>
-            </div>
-            <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button>
-          </div>
-          ` : ''}
-
-          <div class="te-form">
-
-            <label class="te-field">
-              <span>Titel <span class="te-required" aria-hidden="true">*</span></span>
-              <input type="text" id="te-title" maxlength="200" required
-                     autocomplete="off" autocapitalize="words" spellcheck="false"
-                     value="${esc(track.title || '')}">
-            </label>
-
-            <div class="te-row te-row-2">
-              <label class="te-field">
-                <span>Artiest</span>
-                <input type="text" id="te-artist" maxlength="200"
-                       autocomplete="off" autocapitalize="words" spellcheck="false"
-                       value="${esc(track.artist || '')}">
-              </label>
-              <label class="te-field">
-                <span>Album</span>
-                <input type="text" id="te-album" maxlength="200"
-                       autocomplete="off" autocapitalize="words" spellcheck="false"
-                       list="te-album-list" value="${esc(track.album || '')}">
-                <datalist id="te-album-list">
-                  ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')}
-                </datalist>
-              </label>
-            </div>
-
-            <label class="te-field">
-              <span>Duur <small>(seconden — automatisch bepaald, hier te overschrijven)</small></span>
-              <input type="number" id="te-duration" min="0" step="1"
-                     inputmode="numeric" pattern="[0-9]*"
-                     value="${track.duration || ''}" placeholder="auto">
-            </label>
-
-            <div class="te-row te-row-2">
-              <label class="te-field">
-                <span>Eigenaar / credit <small>(copyright-houder)</small></span>
-                <div class="te-credit-row">
-                  <input type="text" id="te-credit" maxlength="200"
-                         autocomplete="off" spellcheck="false"
-                         placeholder="bv. © 2025 Mara Vos"
-                         value="${esc(track.credit || '')}">
-                  <button type="button" class="te-sym-btn" id="te-credit-copyr"
-                          title="© invoegen" aria-label="Copyright-teken invoegen">©</button>
-                </div>
-              </label>
-              <label class="te-field">
-                <span>Licentie</span>
-                <input type="text" id="te-license" maxlength="120"
-                       autocomplete="off" spellcheck="false" list="te-license-list"
-                       placeholder="Alle rechten voorbehouden"
-                       value="${esc(track.license || '')}">
-                <datalist id="te-license-list">
-                  <option value="Alle rechten voorbehouden"></option>
-                  <option value="CC BY 4.0"></option>
-                  <option value="CC BY-SA 4.0"></option>
-                  <option value="CC BY-NC 4.0"></option>
-                  <option value="CC BY-NC-SA 4.0"></option>
-                  <option value="CC BY-ND 4.0"></option>
-                  <option value="CC0 1.0 (publiek domein)"></option>
-                </datalist>
-              </label>
-            </div>
-
-            <div class="te-field">
-              <span>Open in <small>(links naar dezelfde track elders)</small></span>
-              <input type="url" id="te-link-spotify" inputmode="url" autocomplete="off" spellcheck="false"
-                     placeholder="Spotify-URL (https://open.spotify.com/…)" value="${esc(track.link_spotify || '')}">
-              <input type="url" id="te-link-youtube" inputmode="url" autocomplete="off" spellcheck="false"
-                     placeholder="YouTube-URL (https://youtu.be/…)" value="${esc(track.link_youtube || '')}">
-              <input type="url" id="te-link-soundcloud" inputmode="url" autocomplete="off" spellcheck="false"
-                     placeholder="SoundCloud-URL (https://soundcloud.com/…)" value="${esc(track.link_soundcloud || '')}">
-            </div>
-
-            <div class="te-field">
-              <span>Audiobestand</span>
-              <div class="te-cover-btn-row">
-                <input type="file" id="te-audio-file" accept="audio/*,.mp3,.wav,.m4a,.flac,.ogg,.aac" hidden>
-                <button type="button" class="te-cover-btn" id="te-audio-pick">${track.stream_url ? '🔁 Vervang audiobestand' : '🎵 Audiobestand kiezen'}</button>
-                <span class="te-cover-status" id="te-audio-status"></span>
-              </div>
-            </div>
-
-            <div class="te-field">
-              <span>Cover</span>
-              <div class="te-cover-row">
-                <div class="te-cover-thumb" id="te-cover-thumb" tabindex="0" role="button"
-                     aria-label="Klik om cover te kiezen">
-                  ${track.cover_url
-                    ? `<img src="${esc(track.cover_url)}" alt="">`
-                    : `<span class="te-cover-empty">🎨</span>`}
-                </div>
-                <input type="file" id="te-cover-file"
-                       accept="image/jpeg,image/png,image/webp,image/gif" hidden>
-                <div class="te-cover-actions">
-                  <div class="te-cover-btn-row">
-                    <button type="button" class="te-cover-btn" id="te-cover-pick">
-                      📷 Foto kiezen
-                    </button>
-                    ${track.cover_url ? `
-                    <button type="button" class="te-cover-btn te-cover-btn-remove" id="te-cover-remove">
-                      × Verwijder
-                    </button>` : ''}
-                  </div>
-                  <input type="text" id="te-cover-url" inputmode="url"
-                         placeholder="/media/… of https://…"
-                         autocomplete="off" autocapitalize="none" spellcheck="false"
-                         value="${esc(track.cover_url || '')}">
-                  <div class="te-cover-status" id="te-cover-status"></div>
-                </div>
-              </div>
-            </div>
-
-          </div>
-        </div>
-
-        <div class="te-footer">
-          <button type="button" class="te-btn" id="te-cancel">Annuleren</button>
-          <div class="te-footer-spacer"></div>
-          <button type="button" class="te-btn te-btn-primary" id="te-save">💾 Opslaan</button>
-        </div>
-      </div>
-    `;
-
-    document.body.appendChild(backdrop);
-    document.body.classList.add('te-modal-open');
-
-    const $ = sel => backdrop.querySelector(sel);
-
-    // ── Inline preview player (routed through global mini-player) ──
-    // We don't build our own <audio>; instead we tell the global
-    // window.pcmsAudioPlayer to load this single track. Visual state
-    // syncs against the global audio element so toggle works correctly
-    // even if the user pauses from the mini-bar.
-    const previewBtn = $('#te-preview-play');
-    if (previewBtn) {
-      const setPreviewState = (playing) => {
-        previewBtn.textContent = playing ? '⏸' : '▶';
-        previewBtn.classList.toggle('is-playing', playing);
-        previewBtn.setAttribute('aria-label', playing ? 'Pauzeren' : 'Afspelen');
-      };
-      const isOurTrack = () => {
-        // audio.src is a blob: URL (Spotify-style playback) — compare against
-        // the player's logical current-track URL instead.
-        const player = window.pcmsAudioPlayer;
-        const cur = player && player.currentTrack();
-        return !!(cur && track.stream_url && cur.url === track.stream_url);
-      };
-      const resync = () => {
-        const audio = document.getElementById('audio-element');
-        const playing = audio && !audio.paused && !audio.ended && isOurTrack();
-        setPreviewState(!!playing);
-      };
-
-      previewBtn.addEventListener('click', () => {
-        const player = window.pcmsAudioPlayer;
-        if (!player || !track.stream_url) {
-          console.warn('preview: miniplayer or url missing');
-          return;
-        }
-        const audio = document.getElementById('audio-element');
-        if (audio && isOurTrack()) {
-          // Same track loaded — toggle
-          if (audio.paused) player.play(); else player.pause();
-        } else {
-          player.setQueue([{
-            url:    track.stream_url,
-            title:  track.title  || '(zonder titel)',
-            artist: track.artist || '',
-            album:  track.album  || '',
-            cover:  track.cover_url || '',
-          }], 0);
-        }
-      });
-
-      const audio = document.getElementById('audio-element');
-      if (audio) {
-        const evs = ['play', 'pause', 'ended', 'loadstart', 'emptied'];
-        evs.forEach(ev => audio.addEventListener(ev, resync));
-        // Detach listeners on close so we don't leak them
-        backdrop._previewCleanup = () => {
-          evs.forEach(ev => audio.removeEventListener(ev, resync));
-        };
-        resync();   // initial state
-      }
-    }
-
-    function close() {
-      // Detach our resync listeners (mini-player stays running)
-      if (backdrop._previewCleanup) backdrop._previewCleanup();
-      document.body.classList.remove('te-modal-open');
-      document.removeEventListener('keydown', onEsc);
-      backdrop.remove();
-    }
-    function onEsc(e) { if (e.key === 'Escape') close(); }
-    document.addEventListener('keydown', onEsc);
-
-    // A click on the backdrop does NOT close the editor — too easy to lose edits by
-    // mis-clicking outside. Close deliberately via ×, Cancel or Esc.
-    $('.te-close').addEventListener('click', close);
-    $('#te-cancel').addEventListener('click', close);
-
-    // ── Cover picking + URL paste + drag-drop ────────────────
-    const thumb     = $('#te-cover-thumb');
-    const fileInput = $('#te-cover-file');
-    const urlInput  = $('#te-cover-url');
-    const status    = $('#te-cover-status');
-    const pickBtn   = $('#te-cover-pick');
-    const removeBtn = $('#te-cover-remove');
-
-    function setStatus(text, kind) {
-      status.textContent = text || '';
-      status.className = 'te-cover-status' + (kind ? ' is-' + kind : '');
-    }
-    function setThumb(url) {
-      if (url) {
-        thumb.innerHTML = `<img src="${esc(url)}" alt="">`;
-      } else {
-        thumb.innerHTML = `<span class="te-cover-empty">🎨</span>`;
-      }
-    }
-
-    pickBtn.addEventListener('click', () => fileInput.click());
-    thumb.addEventListener('click', () => fileInput.click());
-    thumb.addEventListener('keydown', e => {
-      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
-    });
-
-    async function uploadCoverFile(file) {
-      if (!file) return;
-      if (!/^image\//.test(file.type)) {
-        setStatus('Alleen afbeeldingen toegestaan', 'error'); return;
-      }
-      setStatus('Uploaden…', null);
-      const fd = new FormData();
-      fd.append('cover', file);
-      try {
-        const r = await fetch('/admin/audio/api/' + encodeURIComponent(id) + '/cover', {
-          method: 'POST', body: fd, credentials: 'same-origin',
-        });
-        const j = await r.json();
-        if (!r.ok || !j.ok) throw new Error(j.error || 'Upload mislukt');
-        urlInput.value = j.cover_url || '';
-        setThumb(j.cover_url);
-        setStatus('✓ Geüpload', 'ok');
-      } catch (err) {
-        setStatus('Mislukt: ' + err.message, 'error');
-      }
-    }
-
-    fileInput.addEventListener('change', e => {
-      const f = e.target.files && e.target.files[0];
-      if (f) uploadCoverFile(f);
-      fileInput.value = '';
-    });
-
-    // Drag-drop on thumb (desktop nicety)
-    ['dragenter', 'dragover'].forEach(ev =>
-      thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.add('is-dragover'); }));
-    ['dragleave', 'drop'].forEach(ev =>
-      thumb.addEventListener(ev, e => { e.preventDefault(); thumb.classList.remove('is-dragover'); }));
-    thumb.addEventListener('drop', e => {
-      const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
-      if (f) uploadCoverFile(f);
-    });
-
-    // URL paste auto-preview
-    urlInput.addEventListener('input', () => {
-      const v = urlInput.value.trim();
-      setThumb(v);
-    });
-
-    if (removeBtn) {
-      removeBtn.addEventListener('click', () => {
-        urlInput.value = '';
-        setThumb('');
-        removeBtn.remove();
-      });
-    }
-
-    // ── Replace the audio file of this track ──────────────────
-    const aPick = $('#te-audio-pick');
-    const aFile = $('#te-audio-file');
-    const aStatus = $('#te-audio-status');
-    if (aPick && aFile) {
-      aPick.addEventListener('click', () => aFile.click());
-      aFile.addEventListener('change', async (e) => {
-        const f = e.target.files && e.target.files[0];
-        aFile.value = '';
-        if (!f) return;
-        aStatus.textContent = '⏳ Converteren… (kan even duren)'; aStatus.className = 'te-cover-status';
-        aPick.disabled = true;
-        try {
-          const fd = new FormData();
-          fd.append('audio', f);
-          const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id) + '/replace-audio', fd);
-          if (!j.ok) throw new Error(j.error || 'mislukt');
-          aStatus.textContent = '✓ Vervangen'; aStatus.className = 'te-cover-status is-ok';
-          track.stream_url = j.stream_url || track.stream_url;
-          if (j.duration) { const d = $('#te-duration'); if (d) d.value = j.duration; }
-        } catch (err) {
-          aStatus.textContent = 'Mislukt: ' + err.message; aStatus.className = 'te-cover-status is-error';
-        } finally { aPick.disabled = false; }
-      });
-    }
-
-    // ── Insert © symbol into the credit field ─────────────────
-    const copyrBtn = $('#te-credit-copyr');
-    if (copyrBtn) {
-      copyrBtn.addEventListener('click', () => {
-        const inp = $('#te-credit');
-        if (!inp) return;
-        const sym = '© ';
-        const start = inp.selectionStart != null ? inp.selectionStart : inp.value.length;
-        const end = inp.selectionEnd != null ? inp.selectionEnd : inp.value.length;
-        inp.value = inp.value.slice(0, start) + sym + inp.value.slice(end);
-        inp.focus();
-        const pos = start + sym.length;
-        try { inp.setSelectionRange(pos, pos); } catch (e) {}
-      });
-    }
-
-    // ── Save ────────────────────────────────────────────────
-    $('#te-save').addEventListener('click', async () => {
-      const titleEl = $('#te-title');
-      const title = titleEl.value.trim();
-      if (!title) {
-        titleEl.focus();
-        alert('Titel is verplicht');
-        return;
-      }
-      const saveBtn = $('#te-save');
-      saveBtn.disabled = true;
-      saveBtn.textContent = '⏳ Opslaan…';
-
-      try {
-        const j = await api('POST', '/admin/audio/api/' + encodeURIComponent(id), {
-          title,
-          artist: $('#te-artist').value.trim() || null,
-          album:  $('#te-album').value.trim() || null,
-          credit:  $('#te-credit').value.trim() || null,
-          license: $('#te-license').value.trim() || null,
-          link_spotify:    $('#te-link-spotify').value.trim() || null,
-          link_youtube:    $('#te-link-youtube').value.trim() || null,
-          link_soundcloud: $('#te-link-soundcloud').value.trim() || null,
-          duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
-          cover_url: urlInput.value.trim() || null,
-        });
-        if (!j.ok) throw new Error(j.error || 'Opslaan mislukt');
-        if (typeof onSaved === 'function') onSaved(j.track || { id, title,
-          artist: $('#te-artist').value.trim() || null,
-          album:  $('#te-album').value.trim() || null,
-          cover_url: urlInput.value.trim() || null });
-        close();
-      } catch (err) {
-        alert('Opslaan mislukt: ' + err.message);
-        saveBtn.disabled = false;
-        saveBtn.textContent = '💾 Opslaan';
-      }
-    });
-
-    // ── Auto-duration ───────────────────────────────────────────
-    // The server already determines duration automatically on upload. This is the
-    // fallback/UX layer: if an admin opens an existing track without a duration,
-    // we read it from the audio metadata and fill the field — so you never need
-    // to type seconds manually. An existing value is never overwritten. We fetch
-    // the bytes via the same header gate as the player (X-Audio-Player).
-    (async function autoDuration() {
-      const durEl = $('#te-duration');
-      if (!durEl || !track.stream_url) return;
-      if (durEl.value && Number(durEl.value) > 0) return;  // already filled → leave it alone
-      let objUrl = null;
-      try {
-        const r = await fetch(track.stream_url, { credentials: 'same-origin', headers: { 'X-Audio-Player': '1' } });
-        if (!r.ok) return;
-        objUrl = URL.createObjectURL(await r.blob());
-        const probe = new Audio();
-        probe.preload = 'metadata';
-        probe.addEventListener('loadedmetadata', () => {
-          if (isFinite(probe.duration) && probe.duration > 0 && !(durEl.value && Number(durEl.value) > 0)) {
-            durEl.value = Math.round(probe.duration);
-          }
-          if (objUrl) URL.revokeObjectURL(objUrl);
-        });
-        probe.addEventListener('error', () => { if (objUrl) URL.revokeObjectURL(objUrl); });
-        probe.src = objUrl;
-      } catch (e) { if (objUrl) URL.revokeObjectURL(objUrl); }
-    })();
-
-    // Focus title for fast typing
-    setTimeout(() => $('#te-title').focus(), 60);
-  };
-})();
Index: src/assets/js/shared/post-music-type.js
===================================================================
--- src/assets/js/shared/post-music-type.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,125 +1,0 @@
-/**
- * De regel: welk soort uitgave is een post? (shaer-cyg)
- *
- * WAAROM DIT ONDER assets/ STAAT. De regel wordt op twee plaatsen gebruikt: de
- * server leidt er het type van bestaande posts mee af, en de editor laat het
- * type er live door volgen als je muziek invoegt. Dat MOET dezelfde regel zijn.
- * Twee kopieën zouden niet luidruchtig kapotgaan maar stil uit elkaar lopen --
- * de editor zegt album, de server zegt playlist, en niemand die het merkt. De
- * browser kan alleen bij /assets, de server kan overal bij; dus staat hij hier,
- * en importeert de server hem vanuit services/music.
- *
- * Deze module is bewust PUUR: geen database, geen DOM, geen fetch. Wie hem
- * gebruikt levert zelf een opzoeker voor de soort van een playlist.
- *
- * DE REGEL GAAT OVER IDENTITEIT, NIET OVER TELLEN (Robins herformulering, 9-8):
- * een post neemt het type van zijn muziek over als hij precies EEN muzikale
- * eenheid bevat. Zijn het er meer, dan is de post een post die naar muziek
- * verwijst, en houden de collecties hun eigen identiteit.
- *
- *   losse track(s)                -> playlist   metadata van de post geleend
- *   een collectie                 -> die soort  metadata van de post geleend
- *   een collectie + losse tracks  -> album      de losse zijn BONUS-TRACKS
- *   een MIXTAPE + losse tracks    -> mixtape    de losse zijn BONUS-TRACKS
- *   twee of meer collecties       -> post       NIETS geleend
- *
- * Die vierde regel is de uitzondering op de derde en staat er met opzet naast:
- * bonustracks maken van een uitgave een album, maar een bandje is geen uitgave.
- *
- * Waarom de lening bij de laatste vervalt: die bestaat omdat een collectie soms
- * dun is -- geen eigen hoes, geen eigen titel. Bij twee is de post niet meer de
- * drager van EEN identiteit, en vervalt de reden vanzelf.
- *
- * WIE KIEST ALBUM OF PLAYLIST: dat gebeurt wanneer de PLAYLIST wordt gemaakt.
- * Die keuze staat in playlists.kind en wordt hier OPGEZOCHT, niet afgeleid --
- * een post om een album is een album, ook al heet de shortcode [[playlist:...]].
- *
- * EN ALS WE HET NIET WETEN: een gewone post met insluitingen die de post als
- * context hebben. Geen noodgreep maar de rustende toestand; tracks wijzen met
- * `context` toch al terug naar hun post, dus er gaat niets verloren.
- */
-
-// Dezelfde patronen als de renderer in AudioEmbedService: wat daar niet
-// insluit, telt hier niet mee. Anders zou een shortcode die niets oplevert wel
-// het type van de post kunnen bepalen.
-// De soorten die een playlist kan zijn, en dit is de ENIGE lijst. PlaylistService
-// en music/index.js halen hem hier op, precies omdat het andersom niet kan:
-// deze module draait ook in de browser en mag niets van de database weten.
-//
-// Waarom dat uitmaakt: bij twee soorten stond de keuze vijf keer als
-// `x === 'playlist' ? 'playlist' : 'album'` verspreid over drie bestanden. Zo'n
-// vorm valt niet om bij een derde soort, hij slikt hem -- een mixtape werd
-// stilzwijgend een album en ging als Album de deur uit.
-export const SOORTEN = ['album', 'playlist', 'mixtape'];
-
-const RE_PLAYLIST = /\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi;
-const RE_ALBUM    = /\[\[album:([^\]]+)\]\]/g;
-const RE_TRACK    = /\[\[track:([A-Za-z0-9_-]+)\]\]/g;
-
-/**
- * @param {string} content    de HTML/tekst van de post
- * @param {(id: string) => ('album'|'playlist'|null)} kindVan
- *        de gekozen soort van een playlist, of null als hij onbekend is
- * @returns {null|{type, collectie?, collecties?, tracks, bonus, onbekend?, leentMetadata}}
- */
-export function afleidenUitInsluitingen(content, kindVan) {
-  const c = String(content || '');
-  const zoek = typeof kindVan === 'function' ? kindVan : () => null;
-  const uniek = (re) => [...new Set([...c.matchAll(re)].map((m) => m[1].trim()))];
-
-  const playlists = uniek(RE_PLAYLIST);
-  const albums    = uniek(RE_ALBUM);
-  const tracks    = uniek(RE_TRACK);
-
-  if (!playlists.length && !albums.length && !tracks.length) return null;
-
-  // De soort van een playlist is een gegeven, geen afleiding.
-  const onbekend = [];
-  const uitPlaylists = playlists.map((id) => {
-    const kind = zoek(id);
-    if (!SOORTEN.includes(kind)) { onbekend.push(id); return null; }
-    return { soort: kind, id };
-  }).filter(Boolean);
-
-  const collecties = [
-    ...uitPlaylists,
-    ...albums.map((naam) => ({ soort: 'album', naam })),
-  ];
-
-  // Onbekende situatie -> gewone post. De insluitingen blijven staan en houden
-  // de post als context; alleen het label wordt niet verzonnen.
-  if (onbekend.length) {
-    return { type: 'post', collecties, tracks, bonus: [], onbekend, leentMetadata: false };
-  }
-
-  if (!collecties.length) {
-    // Ook EEN losse track wordt een playlist: naar buiten toe is er dan altijd
-    // een collectie om naar te wijzen. Hoe Klonkt dat toont is een aparte vraag.
-    return { type: 'playlist', collectie: null, tracks, bonus: [], leentMetadata: true };
-  }
-
-  if (collecties.length === 1) {
-    const c0 = collecties[0];
-    // EEN MIXTAPE BLIJFT EEN MIXTAPE, ook met losse tracks erbij. De regel
-    // hieronder maakt van collectie + losse tracks een album met bonustracks, en
-    // dat klopt voor een uitgave: extra nummers bij een plaat zijn bonus. Een
-    // bandje is geen uitgave. Er een album van maken omdat er een los nummer
-    // naast staat zou het ding hernoemen op grond van iets wat er niet bij
-    // hoort. Album en playlist houden bewust hun oude gedrag: dat zijn
-    // bestaande posts en die mogen hier niet stilletjes van soort wisselen.
-    if (c0.soort === 'mixtape') {
-      return { type: 'mixtape', collectie: c0, tracks: [], bonus: tracks, leentMetadata: true };
-    }
-    // Losse tracks naast een collectie zijn geen rommelrestje maar bonus-tracks,
-    // en dat maakt het geheel een album.
-    if (tracks.length) return { type: 'album', collectie: c0, tracks: [], bonus: tracks, leentMetadata: true };
-    return { type: c0.soort, collectie: c0, tracks: [], bonus: [], leentMetadata: true };
-  }
-
-  return { type: 'post', collecties, tracks, bonus: [], leentMetadata: false };
-}
-
-/** De playlist-ids die in een tekst worden ingesloten. */
-export function ingeslotenPlaylists(content) {
-  return [...new Set([...String(content || '').matchAll(RE_PLAYLIST)].map((m) => m[1].trim()))];
-}
Index: src/assets/spinner.svg
===================================================================
--- src/assets/spinner.svg	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,1 +1,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="#000" stroke-width="2.6" stroke-linecap="round" stroke-dasharray="44 100"/></svg>
Index: src/assets/vendor/cropper.min.css
===================================================================
--- src/assets/vendor/cropper.min.css	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,9 +1,0 @@
-/*!
- * Cropper.js v1.6.2
- * https://fengyuanchen.github.io/cropperjs
- *
- * Copyright 2015-present Chen Fengyuan
- * Released under the MIT license
- *
- * Date: 2024-04-21T07:43:02.731Z
- */.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
Index: src/assets/vendor/cropper.min.js
===================================================================
--- src/assets/vendor/cropper.min.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,10 +1,0 @@
-/*!
- * Cropper.js v1.6.2
- * https://fengyuanchen.github.io/cropperjs
- *
- * Copyright 2015-present Chen Fengyuan
- * Released under the MIT license
- *
- * Date: 2024-04-21T07:43:05.335Z
- */
-!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Cropper=e()}(this,function(){"use strict";function C(e,t){var i,a=Object.keys(e);return Object.getOwnPropertySymbols&&(i=Object.getOwnPropertySymbols(e),t&&(i=i.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),a.push.apply(a,i)),a}function S(a){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?C(Object(n),!0).forEach(function(t){var e,i;e=a,i=n[t=t],(t=D(t))in e?Object.defineProperty(e,t,{value:i,enumerable:!0,configurable:!0,writable:!0}):e[t]=i}):Object.getOwnPropertyDescriptors?Object.defineProperties(a,Object.getOwnPropertyDescriptors(n)):C(Object(n)).forEach(function(t){Object.defineProperty(a,t,Object.getOwnPropertyDescriptor(n,t))})}return a}function D(t){t=function(t,e){if("object"!=typeof t||!t)return t;var i=t[Symbol.toPrimitive];if(void 0===i)return("string"===e?String:Number)(t);if("object"!=typeof(i=i.call(t,e||"default")))return i;throw new TypeError("@@toPrimitive must return a primitive value.")}(t,"string");return"symbol"==typeof t?t:t+""}function j(t){return(j="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function A(t,e){for(var i=0;i<e.length;i++){var a=e[i];a.enumerable=a.enumerable||!1,a.configurable=!0,"value"in a&&(a.writable=!0),Object.defineProperty(t,D(a.key),a)}}function P(t){return function(t){if(Array.isArray(t))return a(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){var i;if(t)return"string"==typeof t?a(t,e):"Map"===(i="Object"===(i=Object.prototype.toString.call(t).slice(8,-1))&&t.constructor?t.constructor.name:i)||"Set"===i?Array.from(t):"Arguments"===i||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(i)?a(t,e):void 0}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function a(t,e){(null==e||e>t.length)&&(e=t.length);for(var i=0,a=new Array(e);i<e;i++)a[i]=t[i];return a}var t="undefined"!=typeof window&&void 0!==window.document,h=t?window:{},e=!(!t||!h.document.documentElement)&&"ontouchstart"in h.document.documentElement,i=t&&"PointerEvent"in h,c="cropper",I="all",U="crop",q="move",$="zoom",B="e",k="w",O="s",T="n",E="ne",W="nw",H="se",N="sw",Q="".concat(c,"-crop"),K="".concat(c,"-disabled"),L="".concat(c,"-hidden"),Z="".concat(c,"-hide"),G="".concat(c,"-invisible"),n="".concat(c,"-modal"),V="".concat(c,"-move"),d="".concat(c,"Action"),m="".concat(c,"Preview"),F="crop",J="move",_="none",tt="crop",et="cropend",it="cropmove",at="cropstart",nt="dblclick",ot=i?"pointerdown":e?"touchstart":"mousedown",ht=i?"pointermove":e?"touchmove":"mousemove",rt=i?"pointerup pointercancel":e?"touchend touchcancel":"mouseup",st="zoom",ct="image/jpeg",dt=/^e|w|s|n|se|sw|ne|nw|all|crop|move|zoom$/,lt=/^data:/,pt=/^data:image\/jpeg;base64,/,mt=/^img|canvas$/i,ut={viewMode:0,dragMode:F,initialAspectRatio:NaN,aspectRatio:NaN,data:null,preview:"",responsive:!0,restore:!0,checkCrossOrigin:!0,checkOrientation:!0,modal:!0,guides:!0,center:!0,highlight:!0,background:!0,autoCrop:!0,autoCropArea:.8,movable:!0,rotatable:!0,scalable:!0,zoomable:!0,zoomOnTouch:!0,zoomOnWheel:!0,wheelZoomRatio:.1,cropBoxMovable:!0,cropBoxResizable:!0,toggleDragModeOnDblclick:!0,minCanvasWidth:0,minCanvasHeight:0,minCropBoxWidth:0,minCropBoxHeight:0,minContainerWidth:200,minContainerHeight:100,ready:null,cropstart:null,cropmove:null,cropend:null,crop:null,zoom:null},gt=Number.isNaN||h.isNaN;function p(t){return"number"==typeof t&&!gt(t)}function ft(t){return 0<t&&t<1/0}function vt(t){return void 0===t}function o(t){return"object"===j(t)&&null!==t}var wt=Object.prototype.hasOwnProperty;function u(t){if(!o(t))return!1;try{var e=t.constructor,i=e.prototype;return e&&i&&wt.call(i,"isPrototypeOf")}catch(t){return!1}}function l(t){return"function"==typeof t}var bt=Array.prototype.slice;function yt(t){return Array.from?Array.from(t):bt.call(t)}function z(i,a){return i&&l(a)&&(Array.isArray(i)||p(i.length)?yt(i).forEach(function(t,e){a.call(i,t,e,i)}):o(i)&&Object.keys(i).forEach(function(t){a.call(i,i[t],t,i)})),i}var g=Object.assign||function(i){for(var t=arguments.length,e=new Array(1<t?t-1:0),a=1;a<t;a++)e[a-1]=arguments[a];return o(i)&&0<e.length&&e.forEach(function(e){o(e)&&Object.keys(e).forEach(function(t){i[t]=e[t]})}),i},xt=/\.\d*(?:0|9){12}\d*$/;function Y(t,e){e=1<arguments.length&&void 0!==e?e:1e11;return xt.test(t)?Math.round(t*e)/e:t}var Mt=/^width|height|left|top|marginLeft|marginTop$/;function f(t,e){var i=t.style;z(e,function(t,e){Mt.test(e)&&p(t)&&(t="".concat(t,"px")),i[e]=t})}function v(t,e){var i;e&&(p(t.length)?z(t,function(t){v(t,e)}):t.classList?t.classList.add(e):(i=t.className.trim())?i.indexOf(e)<0&&(t.className="".concat(i," ").concat(e)):t.className=e)}function X(t,e){e&&(p(t.length)?z(t,function(t){X(t,e)}):t.classList?t.classList.remove(e):0<=t.className.indexOf(e)&&(t.className=t.className.replace(e,"")))}function r(t,e,i){e&&(p(t.length)?z(t,function(t){r(t,e,i)}):(i?v:X)(t,e))}var Ct=/([a-z\d])([A-Z])/g;function Dt(t){return t.replace(Ct,"$1-$2").toLowerCase()}function Bt(t,e){return o(t[e])?t[e]:t.dataset?t.dataset[e]:t.getAttribute("data-".concat(Dt(e)))}function w(t,e,i){o(i)?t[e]=i:t.dataset?t.dataset[e]=i:t.setAttribute("data-".concat(Dt(e)),i)}var kt,Ot,Tt=/\s\s*/,Et=(Ot=!1,t&&(kt=!1,i=function(){},e=Object.defineProperty({},"once",{get:function(){return Ot=!0,kt},set:function(t){kt=t}}),h.addEventListener("test",i,e),h.removeEventListener("test",i,e)),Ot);function s(i,t,a,e){var n=3<arguments.length&&void 0!==e?e:{},o=a;t.trim().split(Tt).forEach(function(t){var e;Et||(e=i.listeners)&&e[t]&&e[t][a]&&(o=e[t][a],delete e[t][a],0===Object.keys(e[t]).length&&delete e[t],0===Object.keys(e).length)&&delete i.listeners,i.removeEventListener(t,o,n)})}function b(o,t,h,e){var r=3<arguments.length&&void 0!==e?e:{},s=h;t.trim().split(Tt).forEach(function(a){var t,n;r.once&&!Et&&(t=o.listeners,s=function(){delete n[a][h],o.removeEventListener(a,s,r);for(var t=arguments.length,e=new Array(t),i=0;i<t;i++)e[i]=arguments[i];h.apply(o,e)},(n=void 0===t?{}:t)[a]||(n[a]={}),n[a][h]&&o.removeEventListener(a,n[a][h],r),n[a][h]=s,o.listeners=n),o.addEventListener(a,s,r)})}function y(t,e,i){var a;return l(Event)&&l(CustomEvent)?a=new CustomEvent(e,{detail:i,bubbles:!0,cancelable:!0}):(a=document.createEvent("CustomEvent")).initCustomEvent(e,!0,!0,i),t.dispatchEvent(a)}function Wt(t){t=t.getBoundingClientRect();return{left:t.left+(window.pageXOffset-document.documentElement.clientLeft),top:t.top+(window.pageYOffset-document.documentElement.clientTop)}}var Ht=h.location,Nt=/^(\w+:)\/\/([^:/?#]*):?(\d*)/i;function Lt(t){t=t.match(Nt);return null!==t&&(t[1]!==Ht.protocol||t[2]!==Ht.hostname||t[3]!==Ht.port)}function zt(t){var e="timestamp=".concat((new Date).getTime());return t+(-1===t.indexOf("?")?"?":"&")+e}function x(t){var e=t.rotate,i=t.scaleX,a=t.scaleY,n=t.translateX,t=t.translateY,o=[],n=(p(n)&&0!==n&&o.push("translateX(".concat(n,"px)")),p(t)&&0!==t&&o.push("translateY(".concat(t,"px)")),p(e)&&0!==e&&o.push("rotate(".concat(e,"deg)")),p(i)&&1!==i&&o.push("scaleX(".concat(i,")")),p(a)&&1!==a&&o.push("scaleY(".concat(a,")")),o.length?o.join(" "):"none");return{WebkitTransform:n,msTransform:n,transform:n}}function M(t,e){var i=t.pageX,t=t.pageY,a={endX:i,endY:t};return e?a:S({startX:i,startY:t},a)}function R(t,e){var i,a=t.aspectRatio,n=t.height,t=t.width,e=1<arguments.length&&void 0!==e?e:"contain",o=ft(t),h=ft(n);return o&&h?(i=n*a,"contain"===e&&t<i||"cover"===e&&i<t?n=t/a:t=n*a):o?n=t/a:h&&(t=n*a),{width:t,height:n}}var Yt=String.fromCharCode;var Xt=/^data:.*,/;function Rt(t){var e,i,a,n,o,h,r,s=new DataView(t);try{if(255===s.getUint8(0)&&216===s.getUint8(1))for(var c=s.byteLength,d=2;d+1<c;){if(255===s.getUint8(d)&&225===s.getUint8(d+1)){i=d;break}d+=1}if(a=i&&(n=i+10,"Exif"===function(t,e,i){var a="";i+=e;for(var n=e;n<i;n+=1)a+=Yt(t.getUint8(n));return a}(s,i+4,4))&&((r=18761===(o=s.getUint16(n)))||19789===o)&&42===s.getUint16(n+2,r)&&8<=(h=s.getUint32(n+4,r))?n+h:a)for(var l,p=s.getUint16(a,r),m=0;m<p;m+=1)if(l=a+12*m+2,274===s.getUint16(l,r)){l+=8,e=s.getUint16(l,r),s.setUint16(l,1,r);break}}catch(t){e=1}return e}var t={render:function(){this.initContainer(),this.initCanvas(),this.initCropBox(),this.renderCanvas(),this.cropped&&this.renderCropBox()},initContainer:function(){var t=this.element,e=this.options,i=this.container,a=this.cropper,n=Number(e.minContainerWidth),e=Number(e.minContainerHeight),n=(v(a,L),X(t,L),{width:Math.max(i.offsetWidth,0<=n?n:200),height:Math.max(i.offsetHeight,0<=e?e:100)});f(a,{width:(this.containerData=n).width,height:n.height}),v(t,L),X(a,L)},initCanvas:function(){var t=this.containerData,e=this.imageData,i=this.options.viewMode,a=Math.abs(e.rotate)%180==90,n=a?e.naturalHeight:e.naturalWidth,a=a?e.naturalWidth:e.naturalHeight,e=n/a,o=t.width,h=t.height,e=(t.height*e>t.width?3===i?o=t.height*e:h=t.width/e:3===i?h=t.width/e:o=t.height*e,{aspectRatio:e,naturalWidth:n,naturalHeight:a,width:o,height:h});this.canvasData=e,this.limited=1===i||2===i,this.limitCanvas(!0,!0),e.width=Math.min(Math.max(e.width,e.minWidth),e.maxWidth),e.height=Math.min(Math.max(e.height,e.minHeight),e.maxHeight),e.left=(t.width-e.width)/2,e.top=(t.height-e.height)/2,e.oldLeft=e.left,e.oldTop=e.top,this.initialCanvasData=g({},e)},limitCanvas:function(t,e){var i=this.options,a=this.containerData,n=this.canvasData,o=this.cropBoxData,h=i.viewMode,r=n.aspectRatio,s=this.cropped&&o;t&&(t=Number(i.minCanvasWidth)||0,i=Number(i.minCanvasHeight)||0,1<h?(t=Math.max(t,a.width),i=Math.max(i,a.height),3===h&&(t<i*r?t=i*r:i=t/r)):0<h&&(t?t=Math.max(t,s?o.width:0):i?i=Math.max(i,s?o.height:0):s&&((t=o.width)<(i=o.height)*r?t=i*r:i=t/r)),t=(r=R({aspectRatio:r,width:t,height:i})).width,i=r.height,n.minWidth=t,n.minHeight=i,n.maxWidth=1/0,n.maxHeight=1/0),e&&((s?0:1)<h?(r=a.width-n.width,t=a.height-n.height,n.minLeft=Math.min(0,r),n.minTop=Math.min(0,t),n.maxLeft=Math.max(0,r),n.maxTop=Math.max(0,t),s&&this.limited&&(n.minLeft=Math.min(o.left,o.left+(o.width-n.width)),n.minTop=Math.min(o.top,o.top+(o.height-n.height)),n.maxLeft=o.left,n.maxTop=o.top,2===h)&&(n.width>=a.width&&(n.minLeft=Math.min(0,r),n.maxLeft=Math.max(0,r)),n.height>=a.height)&&(n.minTop=Math.min(0,t),n.maxTop=Math.max(0,t))):(n.minLeft=-n.width,n.minTop=-n.height,n.maxLeft=a.width,n.maxTop=a.height))},renderCanvas:function(t,e){var i,a,n,o,h=this.canvasData,r=this.imageData;e&&(e={width:r.naturalWidth*Math.abs(r.scaleX||1),height:r.naturalHeight*Math.abs(r.scaleY||1),degree:r.rotate||0},r=e.width,o=e.height,e=e.degree,i=90==(e=Math.abs(e)%180)?{width:o,height:r}:(a=e%90*Math.PI/180,i=Math.sin(a),n=r*(a=Math.cos(a))+o*i,r=r*i+o*a,90<e?{width:r,height:n}:{width:n,height:r}),a=h.width*((o=i.width)/h.naturalWidth),n=h.height*((e=i.height)/h.naturalHeight),h.left-=(a-h.width)/2,h.top-=(n-h.height)/2,h.width=a,h.height=n,h.aspectRatio=o/e,h.naturalWidth=o,h.naturalHeight=e,this.limitCanvas(!0,!1)),(h.width>h.maxWidth||h.width<h.minWidth)&&(h.left=h.oldLeft),(h.height>h.maxHeight||h.height<h.minHeight)&&(h.top=h.oldTop),h.width=Math.min(Math.max(h.width,h.minWidth),h.maxWidth),h.height=Math.min(Math.max(h.height,h.minHeight),h.maxHeight),this.limitCanvas(!1,!0),h.left=Math.min(Math.max(h.left,h.minLeft),h.maxLeft),h.top=Math.min(Math.max(h.top,h.minTop),h.maxTop),h.oldLeft=h.left,h.oldTop=h.top,f(this.canvas,g({width:h.width,height:h.height},x({translateX:h.left,translateY:h.top}))),this.renderImage(t),this.cropped&&this.limited&&this.limitCropBox(!0,!0)},renderImage:function(t){var e=this.canvasData,i=this.imageData,a=i.naturalWidth*(e.width/e.naturalWidth),n=i.naturalHeight*(e.height/e.naturalHeight);g(i,{width:a,height:n,left:(e.width-a)/2,top:(e.height-n)/2}),f(this.image,g({width:i.width,height:i.height},x(g({translateX:i.left,translateY:i.top},i)))),t&&this.output()},initCropBox:function(){var t=this.options,e=this.canvasData,i=t.aspectRatio||t.initialAspectRatio,t=Number(t.autoCropArea)||.8,a={width:e.width,height:e.height};i&&(e.height*i>e.width?a.height=a.width/i:a.width=a.height*i),this.cropBoxData=a,this.limitCropBox(!0,!0),a.width=Math.min(Math.max(a.width,a.minWidth),a.maxWidth),a.height=Math.min(Math.max(a.height,a.minHeight),a.maxHeight),a.width=Math.max(a.minWidth,a.width*t),a.height=Math.max(a.minHeight,a.height*t),a.left=e.left+(e.width-a.width)/2,a.top=e.top+(e.height-a.height)/2,a.oldLeft=a.left,a.oldTop=a.top,this.initialCropBoxData=g({},a)},limitCropBox:function(t,e){var i,a,n=this.options,o=this.containerData,h=this.canvasData,r=this.cropBoxData,s=this.limited,c=n.aspectRatio;t&&(t=Number(n.minCropBoxWidth)||0,n=Number(n.minCropBoxHeight)||0,i=s?Math.min(o.width,h.width,h.width+h.left,o.width-h.left):o.width,a=s?Math.min(o.height,h.height,h.height+h.top,o.height-h.top):o.height,t=Math.min(t,o.width),n=Math.min(n,o.height),c&&(t&&n?t<n*c?n=t/c:t=n*c:t?n=t/c:n&&(t=n*c),i<a*c?a=i/c:i=a*c),r.minWidth=Math.min(t,i),r.minHeight=Math.min(n,a),r.maxWidth=i,r.maxHeight=a),e&&(s?(r.minLeft=Math.max(0,h.left),r.minTop=Math.max(0,h.top),r.maxLeft=Math.min(o.width,h.left+h.width)-r.width,r.maxTop=Math.min(o.height,h.top+h.height)-r.height):(r.minLeft=0,r.minTop=0,r.maxLeft=o.width-r.width,r.maxTop=o.height-r.height))},renderCropBox:function(){var t=this.options,e=this.containerData,i=this.cropBoxData;(i.width>i.maxWidth||i.width<i.minWidth)&&(i.left=i.oldLeft),(i.height>i.maxHeight||i.height<i.minHeight)&&(i.top=i.oldTop),i.width=Math.min(Math.max(i.width,i.minWidth),i.maxWidth),i.height=Math.min(Math.max(i.height,i.minHeight),i.maxHeight),this.limitCropBox(!1,!0),i.left=Math.min(Math.max(i.left,i.minLeft),i.maxLeft),i.top=Math.min(Math.max(i.top,i.minTop),i.maxTop),i.oldLeft=i.left,i.oldTop=i.top,t.movable&&t.cropBoxMovable&&w(this.face,d,i.width>=e.width&&i.height>=e.height?q:I),f(this.cropBox,g({width:i.width,height:i.height},x({translateX:i.left,translateY:i.top}))),this.cropped&&this.limited&&this.limitCanvas(!0,!0),this.disabled||this.output()},output:function(){this.preview(),y(this.element,tt,this.getData())}},i={initPreview:function(){var t=this.element,i=this.crossOrigin,e=this.options.preview,a=i?this.crossOriginUrl:this.url,n=t.alt||"The image to preview",o=document.createElement("img");i&&(o.crossOrigin=i),o.src=a,o.alt=n,this.viewBox.appendChild(o),this.viewBoxImage=o,e&&("string"==typeof(o=e)?o=t.ownerDocument.querySelectorAll(e):e.querySelector&&(o=[e]),z(this.previews=o,function(t){var e=document.createElement("img");w(t,m,{width:t.offsetWidth,height:t.offsetHeight,html:t.innerHTML}),i&&(e.crossOrigin=i),e.src=a,e.alt=n,e.style.cssText='display:block;width:100%;height:auto;min-width:0!important;min-height:0!important;max-width:none!important;max-height:none!important;image-orientation:0deg!important;"',t.innerHTML="",t.appendChild(e)}))},resetPreview:function(){z(this.previews,function(e){var i=Bt(e,m),i=(f(e,{width:i.width,height:i.height}),e.innerHTML=i.html,e),e=m;if(o(i[e]))try{delete i[e]}catch(t){i[e]=void 0}else if(i.dataset)try{delete i.dataset[e]}catch(t){i.dataset[e]=void 0}else i.removeAttribute("data-".concat(Dt(e)))})},preview:function(){var h=this.imageData,t=this.canvasData,e=this.cropBoxData,r=e.width,s=e.height,c=h.width,d=h.height,l=e.left-t.left-h.left,p=e.top-t.top-h.top;this.cropped&&!this.disabled&&(f(this.viewBoxImage,g({width:c,height:d},x(g({translateX:-l,translateY:-p},h)))),z(this.previews,function(t){var e=Bt(t,m),i=e.width,e=e.height,a=i,n=e,o=1;r&&(n=s*(o=i/r)),s&&e<n&&(a=r*(o=e/s),n=e),f(t,{width:a,height:n}),f(t.getElementsByTagName("img")[0],g({width:c*o,height:d*o},x(g({translateX:-l*o,translateY:-p*o},h))))}))}},e={bind:function(){var t=this.element,e=this.options,i=this.cropper;l(e.cropstart)&&b(t,at,e.cropstart),l(e.cropmove)&&b(t,it,e.cropmove),l(e.cropend)&&b(t,et,e.cropend),l(e.crop)&&b(t,tt,e.crop),l(e.zoom)&&b(t,st,e.zoom),b(i,ot,this.onCropStart=this.cropStart.bind(this)),e.zoomable&&e.zoomOnWheel&&b(i,"wheel",this.onWheel=this.wheel.bind(this),{passive:!1,capture:!0}),e.toggleDragModeOnDblclick&&b(i,nt,this.onDblclick=this.dblclick.bind(this)),b(t.ownerDocument,ht,this.onCropMove=this.cropMove.bind(this)),b(t.ownerDocument,rt,this.onCropEnd=this.cropEnd.bind(this)),e.responsive&&b(window,"resize",this.onResize=this.resize.bind(this))},unbind:function(){var t=this.element,e=this.options,i=this.cropper;l(e.cropstart)&&s(t,at,e.cropstart),l(e.cropmove)&&s(t,it,e.cropmove),l(e.cropend)&&s(t,et,e.cropend),l(e.crop)&&s(t,tt,e.crop),l(e.zoom)&&s(t,st,e.zoom),s(i,ot,this.onCropStart),e.zoomable&&e.zoomOnWheel&&s(i,"wheel",this.onWheel,{passive:!1,capture:!0}),e.toggleDragModeOnDblclick&&s(i,nt,this.onDblclick),s(t.ownerDocument,ht,this.onCropMove),s(t.ownerDocument,rt,this.onCropEnd),e.responsive&&s(window,"resize",this.onResize)}},St={resize:function(){var t,e,i,a,n,o,h;this.disabled||(t=this.options,a=this.container,e=this.containerData,i=a.offsetWidth/e.width,a=a.offsetHeight/e.height,1!=(n=Math.abs(i-1)>Math.abs(a-1)?i:a)&&(t.restore&&(o=this.getCanvasData(),h=this.getCropBoxData()),this.render(),t.restore)&&(this.setCanvasData(z(o,function(t,e){o[e]=t*n})),this.setCropBoxData(z(h,function(t,e){h[e]=t*n}))))},dblclick:function(){var t,e;this.disabled||this.options.dragMode===_||this.setDragMode((t=this.dragBox,e=Q,(t.classList?t.classList.contains(e):-1<t.className.indexOf(e))?J:F))},wheel:function(t){var e=this,i=Number(this.options.wheelZoomRatio)||.1,a=1;this.disabled||(t.preventDefault(),this.wheeling)||(this.wheeling=!0,setTimeout(function(){e.wheeling=!1},50),t.deltaY?a=0<t.deltaY?1:-1:t.wheelDelta?a=-t.wheelDelta/120:t.detail&&(a=0<t.detail?1:-1),this.zoom(-a*i,t))},cropStart:function(t){var e,i=t.buttons,a=t.button;this.disabled||("mousedown"===t.type||"pointerdown"===t.type&&"mouse"===t.pointerType)&&(p(i)&&1!==i||p(a)&&0!==a||t.ctrlKey)||(i=this.options,e=this.pointers,t.changedTouches?z(t.changedTouches,function(t){e[t.identifier]=M(t)}):e[t.pointerId||0]=M(t),a=1<Object.keys(e).length&&i.zoomable&&i.zoomOnTouch?$:Bt(t.target,d),dt.test(a)&&!1!==y(this.element,at,{originalEvent:t,action:a})&&(t.preventDefault(),this.action=a,this.cropping=!1,a===U)&&(this.cropping=!0,v(this.dragBox,n)))},cropMove:function(t){var e,i=this.action;!this.disabled&&i&&(e=this.pointers,t.preventDefault(),!1!==y(this.element,it,{originalEvent:t,action:i}))&&(t.changedTouches?z(t.changedTouches,function(t){g(e[t.identifier]||{},M(t,!0))}):g(e[t.pointerId||0]||{},M(t,!0)),this.change(t))},cropEnd:function(t){var e,i;this.disabled||(e=this.action,i=this.pointers,t.changedTouches?z(t.changedTouches,function(t){delete i[t.identifier]}):delete i[t.pointerId||0],e&&(t.preventDefault(),Object.keys(i).length||(this.action=""),this.cropping&&(this.cropping=!1,r(this.dragBox,n,this.cropped&&this.options.modal)),y(this.element,et,{originalEvent:t,action:e})))}},jt={change:function(t){function e(t){switch(t){case B:f+D.x>y&&(D.x=y-f);break;case k:p+D.x<w&&(D.x=w-p);break;case T:m+D.y<b&&(D.y=b-m);break;case O:v+D.y>x&&(D.y=x-v)}}var i,a,o,n=this.options,h=this.canvasData,r=this.containerData,s=this.cropBoxData,c=this.pointers,d=this.action,l=n.aspectRatio,p=s.left,m=s.top,u=s.width,g=s.height,f=p+u,v=m+g,w=0,b=0,y=r.width,x=r.height,M=!0,C=(!l&&t.shiftKey&&(l=u&&g?u/g:1),this.limited&&(w=s.minLeft,b=s.minTop,y=w+Math.min(r.width,h.width,h.left+h.width),x=b+Math.min(r.height,h.height,h.top+h.height)),c[Object.keys(c)[0]]),D={x:C.endX-C.startX,y:C.endY-C.startY};switch(d){case I:p+=D.x,m+=D.y;break;case B:0<=D.x&&(y<=f||l&&(m<=b||x<=v))?M=!1:(e(B),(u+=D.x)<0&&(d=k,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case T:D.y<=0&&(m<=b||l&&(p<=w||y<=f))?M=!1:(e(T),g-=D.y,m+=D.y,g<0&&(d=O,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case k:D.x<=0&&(p<=w||l&&(m<=b||x<=v))?M=!1:(e(k),u-=D.x,p+=D.x,u<0&&(d=B,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case O:0<=D.y&&(x<=v||l&&(p<=w||y<=f))?M=!1:(e(O),(g+=D.y)<0&&(d=T,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case E:if(l){if(D.y<=0&&(m<=b||y<=f)){M=!1;break}e(T),g-=D.y,m+=D.y,u=g*l}else e(T),e(B),!(0<=D.x)||f<y?u+=D.x:D.y<=0&&m<=b&&(M=!1),(!(D.y<=0)||b<m)&&(g-=D.y,m+=D.y);u<0&&g<0?(d=N,m-=g=-g,p-=u=-u):u<0?(d=W,p-=u=-u):g<0&&(d=H,m-=g=-g);break;case W:if(l){if(D.y<=0&&(m<=b||p<=w)){M=!1;break}e(T),g-=D.y,m+=D.y,p+=s.width-(u=g*l)}else e(T),e(k),!(D.x<=0)||w<p?(u-=D.x,p+=D.x):D.y<=0&&m<=b&&(M=!1),(!(D.y<=0)||b<m)&&(g-=D.y,m+=D.y);u<0&&g<0?(d=H,m-=g=-g,p-=u=-u):u<0?(d=E,p-=u=-u):g<0&&(d=N,m-=g=-g);break;case N:if(l){if(D.x<=0&&(p<=w||x<=v)){M=!1;break}e(k),u-=D.x,p+=D.x,g=u/l}else e(O),e(k),!(D.x<=0)||w<p?(u-=D.x,p+=D.x):0<=D.y&&x<=v&&(M=!1),(!(0<=D.y)||v<x)&&(g+=D.y);u<0&&g<0?(d=E,m-=g=-g,p-=u=-u):u<0?(d=H,p-=u=-u):g<0&&(d=W,m-=g=-g);break;case H:if(l){if(0<=D.x&&(y<=f||x<=v)){M=!1;break}e(B),g=(u+=D.x)/l}else e(O),e(B),!(0<=D.x)||f<y?u+=D.x:0<=D.y&&x<=v&&(M=!1),(!(0<=D.y)||v<x)&&(g+=D.y);u<0&&g<0?(d=W,m-=g=-g,p-=u=-u):u<0?(d=N,p-=u=-u):g<0&&(d=E,m-=g=-g);break;case q:this.move(D.x,D.y),M=!1;break;case $:this.zoom((a=S({},i=c),o=0,z(i,function(n,t){delete a[t],z(a,function(t){var e=Math.abs(n.startX-t.startX),i=Math.abs(n.startY-t.startY),a=Math.abs(n.endX-t.endX),t=Math.abs(n.endY-t.endY),e=Math.sqrt(e*e+i*i),i=(Math.sqrt(a*a+t*t)-e)/e;Math.abs(i)>Math.abs(o)&&(o=i)})}),o),t),M=!1;break;case U:D.x&&D.y?(i=Wt(this.cropper),p=C.startX-i.left,m=C.startY-i.top,u=s.minWidth,g=s.minHeight,0<D.x?d=0<D.y?H:E:D.x<0&&(p-=u,d=0<D.y?N:W),D.y<0&&(m-=g),this.cropped||(X(this.cropBox,L),this.cropped=!0,this.limited&&this.limitCropBox(!0,!0))):M=!1}M&&(s.width=u,s.height=g,s.left=p,s.top=m,this.action=d,this.renderCropBox()),z(c,function(t){t.startX=t.endX,t.startY=t.endY})}},At={crop:function(){return!this.ready||this.cropped||this.disabled||(this.cropped=!0,this.limitCropBox(!0,!0),this.options.modal&&v(this.dragBox,n),X(this.cropBox,L),this.setCropBoxData(this.initialCropBoxData)),this},reset:function(){return this.ready&&!this.disabled&&(this.imageData=g({},this.initialImageData),this.canvasData=g({},this.initialCanvasData),this.cropBoxData=g({},this.initialCropBoxData),this.renderCanvas(),this.cropped)&&this.renderCropBox(),this},clear:function(){return this.cropped&&!this.disabled&&(g(this.cropBoxData,{left:0,top:0,width:0,height:0}),this.cropped=!1,this.renderCropBox(),this.limitCanvas(!0,!0),this.renderCanvas(),X(this.dragBox,n),v(this.cropBox,L)),this},replace:function(e){var t=1<arguments.length&&void 0!==arguments[1]&&arguments[1];return!this.disabled&&e&&(this.isImg&&(this.element.src=e),t?(this.url=e,this.image.src=e,this.ready&&(this.viewBoxImage.src=e,z(this.previews,function(t){t.getElementsByTagName("img")[0].src=e}))):(this.isImg&&(this.replaced=!0),this.options.data=null,this.uncreate(),this.load(e))),this},enable:function(){return this.ready&&this.disabled&&(this.disabled=!1,X(this.cropper,K)),this},disable:function(){return this.ready&&!this.disabled&&(this.disabled=!0,v(this.cropper,K)),this},destroy:function(){var t=this.element;return t[c]&&(t[c]=void 0,this.isImg&&this.replaced&&(t.src=this.originalUrl),this.uncreate()),this},move:function(t){var e=1<arguments.length&&void 0!==arguments[1]?arguments[1]:t,i=this.canvasData,a=i.left,i=i.top;return this.moveTo(vt(t)?t:a+Number(t),vt(e)?e:i+Number(e))},moveTo:function(t){var e=1<arguments.length&&void 0!==arguments[1]?arguments[1]:t,i=this.canvasData,a=!1;return t=Number(t),e=Number(e),this.ready&&!this.disabled&&this.options.movable&&(p(t)&&(i.left=t,a=!0),p(e)&&(i.top=e,a=!0),a)&&this.renderCanvas(!0),this},zoom:function(t,e){var i=this.canvasData;return t=Number(t),this.zoomTo(i.width*(t=t<0?1/(1-t):1+t)/i.naturalWidth,null,e)},zoomTo:function(t,e,i){var a,n,o,h=this.options,r=this.canvasData,s=r.width,c=r.height,d=r.naturalWidth,l=r.naturalHeight;if(0<=(t=Number(t))&&this.ready&&!this.disabled&&h.zoomable){h=d*t,l=l*t;if(!1===y(this.element,st,{ratio:t,oldRatio:s/d,originalEvent:i}))return this;i?(t=this.pointers,d=Wt(this.cropper),t=t&&Object.keys(t).length?(o=n=a=0,z(t,function(t){var e=t.startX,t=t.startY;a+=e,n+=t,o+=1}),{pageX:a/=o,pageY:n/=o}):{pageX:i.pageX,pageY:i.pageY},r.left-=(h-s)*((t.pageX-d.left-r.left)/s),r.top-=(l-c)*((t.pageY-d.top-r.top)/c)):u(e)&&p(e.x)&&p(e.y)?(r.left-=(h-s)*((e.x-r.left)/s),r.top-=(l-c)*((e.y-r.top)/c)):(r.left-=(h-s)/2,r.top-=(l-c)/2),r.width=h,r.height=l,this.renderCanvas(!0)}return this},rotate:function(t){return this.rotateTo((this.imageData.rotate||0)+Number(t))},rotateTo:function(t){return p(t=Number(t))&&this.ready&&!this.disabled&&this.options.rotatable&&(this.imageData.rotate=t%360,this.renderCanvas(!0,!0)),this},scaleX:function(t){var e=this.imageData.scaleY;return this.scale(t,p(e)?e:1)},scaleY:function(t){var e=this.imageData.scaleX;return this.scale(p(e)?e:1,t)},scale:function(t){var e=1<arguments.length&&void 0!==arguments[1]?arguments[1]:t,i=this.imageData,a=!1;return t=Number(t),e=Number(e),this.ready&&!this.disabled&&this.options.scalable&&(p(t)&&(i.scaleX=t,a=!0),p(e)&&(i.scaleY=e,a=!0),a)&&this.renderCanvas(!0,!0),this},getData:function(){var i,a,t=0<arguments.length&&void 0!==arguments[0]&&arguments[0],e=this.options,n=this.imageData,o=this.canvasData,h=this.cropBoxData;return this.ready&&this.cropped?(i={x:h.left-o.left,y:h.top-o.top,width:h.width,height:h.height},a=n.width/n.naturalWidth,z(i,function(t,e){i[e]=t/a}),t&&(o=Math.round(i.y+i.height),h=Math.round(i.x+i.width),i.x=Math.round(i.x),i.y=Math.round(i.y),i.width=h-i.x,i.height=o-i.y)):i={x:0,y:0,width:0,height:0},e.rotatable&&(i.rotate=n.rotate||0),e.scalable&&(i.scaleX=n.scaleX||1,i.scaleY=n.scaleY||1),i},setData:function(t){var e,i=this.options,a=this.imageData,n=this.canvasData,o={};return this.ready&&!this.disabled&&u(t)&&(e=!1,i.rotatable&&p(t.rotate)&&t.rotate!==a.rotate&&(a.rotate=t.rotate,e=!0),i.scalable&&(p(t.scaleX)&&t.scaleX!==a.scaleX&&(a.scaleX=t.scaleX,e=!0),p(t.scaleY))&&t.scaleY!==a.scaleY&&(a.scaleY=t.scaleY,e=!0),e&&this.renderCanvas(!0,!0),i=a.width/a.naturalWidth,p(t.x)&&(o.left=t.x*i+n.left),p(t.y)&&(o.top=t.y*i+n.top),p(t.width)&&(o.width=t.width*i),p(t.height)&&(o.height=t.height*i),this.setCropBoxData(o)),this},getContainerData:function(){return this.ready?g({},this.containerData):{}},getImageData:function(){return this.sized?g({},this.imageData):{}},getCanvasData:function(){var e=this.canvasData,i={};return this.ready&&z(["left","top","width","height","naturalWidth","naturalHeight"],function(t){i[t]=e[t]}),i},setCanvasData:function(t){var e=this.canvasData,i=e.aspectRatio;return this.ready&&!this.disabled&&u(t)&&(p(t.left)&&(e.left=t.left),p(t.top)&&(e.top=t.top),p(t.width)?(e.width=t.width,e.height=t.width/i):p(t.height)&&(e.height=t.height,e.width=t.height*i),this.renderCanvas(!0)),this},getCropBoxData:function(){var t,e=this.cropBoxData;return(t=this.ready&&this.cropped?{left:e.left,top:e.top,width:e.width,height:e.height}:t)||{}},setCropBoxData:function(t){var e,i,a=this.cropBoxData,n=this.options.aspectRatio;return this.ready&&this.cropped&&!this.disabled&&u(t)&&(p(t.left)&&(a.left=t.left),p(t.top)&&(a.top=t.top),p(t.width)&&t.width!==a.width&&(e=!0,a.width=t.width),p(t.height)&&t.height!==a.height&&(i=!0,a.height=t.height),n&&(e?a.height=a.width/n:i&&(a.width=a.height*n)),this.renderCropBox()),this},getCroppedCanvas:function(){var t,e,i,a,n,o,h,r,s,c,d,l,p,m,u,g,f,v,w,b,y,x,M,C,D,B,k,O=0<arguments.length&&void 0!==arguments[0]?arguments[0]:{};return this.ready&&window.HTMLCanvasElement?(B=this.canvasData,u=this.image,l=this.imageData,a=B,v=O,g=l.aspectRatio,e=l.naturalWidth,n=l.naturalHeight,c=void 0===(c=l.rotate)?0:c,d=void 0===(d=l.scaleX)?1:d,l=void 0===(l=l.scaleY)?1:l,i=a.aspectRatio,r=a.naturalWidth,a=a.naturalHeight,h=void 0===(h=v.fillColor)?"transparent":h,p=void 0===(p=v.imageSmoothingEnabled)||p,m=void 0===(m=v.imageSmoothingQuality)?"low":m,o=void 0===(o=v.maxWidth)?1/0:o,k=void 0===(k=v.maxHeight)?1/0:k,t=void 0===(t=v.minWidth)?0:t,v=void 0===(v=v.minHeight)?0:v,w=document.createElement("canvas"),f=w.getContext("2d"),s=R({aspectRatio:i,width:o,height:k}),i=R({aspectRatio:i,width:t,height:v},"cover"),r=Math.min(s.width,Math.max(i.width,r)),s=Math.min(s.height,Math.max(i.height,a)),i=R({aspectRatio:g,width:o,height:k}),a=R({aspectRatio:g,width:t,height:v},"cover"),o=Math.min(i.width,Math.max(a.width,e)),k=Math.min(i.height,Math.max(a.height,n)),g=[-o/2,-k/2,o,k],w.width=Y(r),w.height=Y(s),f.fillStyle=h,f.fillRect(0,0,r,s),f.save(),f.translate(r/2,s/2),f.rotate(c*Math.PI/180),f.scale(d,l),f.imageSmoothingEnabled=p,f.imageSmoothingQuality=m,f.drawImage.apply(f,[u].concat(P(g.map(function(t){return Math.floor(Y(t))})))),f.restore(),t=w,this.cropped?(e=(v=this.getData(O.rounded)).x,i=v.y,a=v.width,n=v.height,1!=(o=t.width/Math.floor(B.naturalWidth))&&(e*=o,i*=o,a*=o,n*=o),h=R({aspectRatio:k=a/n,width:O.maxWidth||1/0,height:O.maxHeight||1/0}),r=R({aspectRatio:k,width:O.minWidth||0,height:O.minHeight||0},"cover"),c=(s=R({aspectRatio:k,width:O.width||(1!=o?t.width:a),height:O.height||(1!=o?t.height:n)})).width,d=s.height,c=Math.min(h.width,Math.max(r.width,c)),d=Math.min(h.height,Math.max(r.height,d)),p=(l=document.createElement("canvas")).getContext("2d"),l.width=Y(c),l.height=Y(d),p.fillStyle=O.fillColor||"transparent",p.fillRect(0,0,c,d),m=O.imageSmoothingEnabled,u=O.imageSmoothingQuality,p.imageSmoothingEnabled=void 0===m||m,u&&(p.imageSmoothingQuality=u),g=t.width,f=t.height,w=i,(v=e)<=-a||g<v?C=x=b=v=0:v<=0?(x=-v,v=0,C=b=Math.min(g,a+v)):v<=g&&(x=0,C=b=Math.min(a,g-v)),b<=0||w<=-n||f<w?D=M=y=w=0:w<=0?(M=-w,w=0,D=y=Math.min(f,n+w)):w<=f&&(M=0,D=y=Math.min(n,f-w)),B=[v,w,b,y],0<C&&0<D&&B.push(x*(k=c/a),M*k,C*k,D*k),p.drawImage.apply(p,[t].concat(P(B.map(function(t){return Math.floor(Y(t))})))),l):t):null},setAspectRatio:function(t){var e=this.options;return this.disabled||vt(t)||(e.aspectRatio=Math.max(0,t)||NaN,this.ready&&(this.initCropBox(),this.cropped)&&this.renderCropBox()),this},setDragMode:function(t){var e,i,a=this.options,n=this.dragBox,o=this.face;return this.ready&&!this.disabled&&(i=a.movable&&t===J,a.dragMode=t=(e=t===F)||i?t:_,w(n,d,t),r(n,Q,e),r(n,V,i),a.cropBoxMovable||(w(o,d,t),r(o,Q,e),r(o,V,i))),this}},Pt=h.Cropper,It=function(){function n(t){var e=1<arguments.length&&void 0!==arguments[1]?arguments[1]:{},i=this,a=n;if(!(i instanceof a))throw new TypeError("Cannot call a class as a function");if(!t||!mt.test(t.tagName))throw new Error("The first argument is required and must be an <img> or <canvas> element.");this.element=t,this.options=g({},ut,u(e)&&e),this.cropped=!1,this.disabled=!1,this.pointers={},this.ready=!1,this.reloading=!1,this.replaced=!1,this.sized=!1,this.sizing=!1,this.init()}return t=n,i=[{key:"noConflict",value:function(){return window.Cropper=Pt,n}},{key:"setDefaults",value:function(t){g(ut,u(t)&&t)}}],(e=[{key:"init",value:function(){var t,e=this.element,i=e.tagName.toLowerCase();if(!e[c]){if(e[c]=this,"img"===i){if(this.isImg=!0,t=e.getAttribute("src")||"",!(this.originalUrl=t))return;t=e.src}else"canvas"===i&&window.HTMLCanvasElement&&(t=e.toDataURL());this.load(t)}}},{key:"load",value:function(t){var e,i,a,n,o,h,r=this;t&&(this.url=t,this.imageData={},e=this.element,(i=this.options).rotatable||i.scalable||(i.checkOrientation=!1),i.checkOrientation&&window.ArrayBuffer?lt.test(t)?pt.test(t)?this.read((h=(h=t).replace(Xt,""),a=atob(h),h=new ArrayBuffer(a.length),z(n=new Uint8Array(h),function(t,e){n[e]=a.charCodeAt(e)}),h)):this.clone():(o=new XMLHttpRequest,h=this.clone.bind(this),this.reloading=!0,(this.xhr=o).onabort=h,o.onerror=h,o.ontimeout=h,o.onprogress=function(){o.getResponseHeader("content-type")!==ct&&o.abort()},o.onload=function(){r.read(o.response)},o.onloadend=function(){r.reloading=!1,r.xhr=null},i.checkCrossOrigin&&Lt(t)&&e.crossOrigin&&(t=zt(t)),o.open("GET",t,!0),o.responseType="arraybuffer",o.withCredentials="use-credentials"===e.crossOrigin,o.send()):this.clone())}},{key:"read",value:function(t){var e=this.options,i=this.imageData,a=Rt(t),n=0,o=1,h=1;1<a&&(this.url=function(t,e){for(var i=[],a=new Uint8Array(t);0<a.length;)i.push(Yt.apply(null,yt(a.subarray(0,8192)))),a=a.subarray(8192);return"data:".concat(e,";base64,").concat(btoa(i.join("")))}(t,ct),n=(t=function(t){var e=0,i=1,a=1;switch(t){case 2:i=-1;break;case 3:e=-180;break;case 4:a=-1;break;case 5:e=90,a=-1;break;case 6:e=90;break;case 7:e=90,i=-1;break;case 8:e=-90}return{rotate:e,scaleX:i,scaleY:a}}(a)).rotate,o=t.scaleX,h=t.scaleY),e.rotatable&&(i.rotate=n),e.scalable&&(i.scaleX=o,i.scaleY=h),this.clone()}},{key:"clone",value:function(){var t=this.element,e=this.url,i=t.crossOrigin,a=e,n=(this.options.checkCrossOrigin&&Lt(e)&&(i=i||"anonymous",a=zt(e)),this.crossOrigin=i,this.crossOriginUrl=a,document.createElement("img"));i&&(n.crossOrigin=i),n.src=a||e,n.alt=t.alt||"The image to crop",(this.image=n).onload=this.start.bind(this),n.onerror=this.stop.bind(this),v(n,Z),t.parentNode.insertBefore(n,t.nextSibling)}},{key:"start",value:function(){function t(t,e){g(a.imageData,{naturalWidth:t,naturalHeight:e,aspectRatio:t/e}),a.initialImageData=g({},a.imageData),a.sizing=!1,a.sized=!0,a.build()}var e,i,a=this,n=this.image,o=(n.onload=null,n.onerror=null,this.sizing=!0,h.navigator&&/(?:iPad|iPhone|iPod).*?AppleWebKit/i.test(h.navigator.userAgent));n.naturalWidth&&!o?t(n.naturalWidth,n.naturalHeight):(e=document.createElement("img"),i=document.body||document.documentElement,(this.sizingImage=e).onload=function(){t(e.width,e.height),o||i.removeChild(e)},e.src=n.src,o||(e.style.cssText="left:0;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;opacity:0;position:absolute;top:0;z-index:-1;",i.appendChild(e)))}},{key:"stop",value:function(){var t=this.image;t.onload=null,t.onerror=null,t.parentNode.removeChild(t),this.image=null}},{key:"build",value:function(){var t,e,i,a,n,o,h,r,s;this.sized&&!this.ready&&(t=this.element,e=this.options,i=this.image,a=t.parentNode,(n=document.createElement("div")).innerHTML='<div class="cropper-container" touch-action="none"><div class="cropper-wrap-box"><div class="cropper-canvas"></div></div><div class="cropper-drag-box"></div><div class="cropper-crop-box"><span class="cropper-view-box"></span><span class="cropper-dashed dashed-h"></span><span class="cropper-dashed dashed-v"></span><span class="cropper-center"></span><span class="cropper-face"></span><span class="cropper-line line-e" data-cropper-action="e"></span><span class="cropper-line line-n" data-cropper-action="n"></span><span class="cropper-line line-w" data-cropper-action="w"></span><span class="cropper-line line-s" data-cropper-action="s"></span><span class="cropper-point point-e" data-cropper-action="e"></span><span class="cropper-point point-n" data-cropper-action="n"></span><span class="cropper-point point-w" data-cropper-action="w"></span><span class="cropper-point point-s" data-cropper-action="s"></span><span class="cropper-point point-ne" data-cropper-action="ne"></span><span class="cropper-point point-nw" data-cropper-action="nw"></span><span class="cropper-point point-sw" data-cropper-action="sw"></span><span class="cropper-point point-se" data-cropper-action="se"></span></div></div>',o=(n=n.querySelector(".".concat(c,"-container"))).querySelector(".".concat(c,"-canvas")),h=n.querySelector(".".concat(c,"-drag-box")),s=(r=n.querySelector(".".concat(c,"-crop-box"))).querySelector(".".concat(c,"-face")),this.container=a,this.cropper=n,this.canvas=o,this.dragBox=h,this.cropBox=r,this.viewBox=n.querySelector(".".concat(c,"-view-box")),this.face=s,o.appendChild(i),v(t,L),a.insertBefore(n,t.nextSibling),X(i,Z),this.initPreview(),this.bind(),e.initialAspectRatio=Math.max(0,e.initialAspectRatio)||NaN,e.aspectRatio=Math.max(0,e.aspectRatio)||NaN,e.viewMode=Math.max(0,Math.min(3,Math.round(e.viewMode)))||0,v(r,L),e.guides||v(r.getElementsByClassName("".concat(c,"-dashed")),L),e.center||v(r.getElementsByClassName("".concat(c,"-center")),L),e.background&&v(n,"".concat(c,"-bg")),e.highlight||v(s,G),e.cropBoxMovable&&(v(s,V),w(s,d,I)),e.cropBoxResizable||(v(r.getElementsByClassName("".concat(c,"-line")),L),v(r.getElementsByClassName("".concat(c,"-point")),L)),this.render(),this.ready=!0,this.setDragMode(e.dragMode),e.autoCrop&&this.crop(),this.setData(e.data),l(e.ready)&&b(t,"ready",e.ready,{once:!0}),y(t,"ready"))}},{key:"unbuild",value:function(){var t;this.ready&&(this.ready=!1,this.unbind(),this.resetPreview(),(t=this.cropper.parentNode)&&t.removeChild(this.cropper),X(this.element,L))}},{key:"uncreate",value:function(){this.ready?(this.unbuild(),this.ready=!1,this.cropped=!1):this.sizing?(this.sizingImage.onload=null,this.sizing=!1,this.sized=!1):this.reloading?(this.xhr.onabort=null,this.xhr.abort()):this.image&&this.stop()}}])&&A(t.prototype,e),i&&A(t,i),Object.defineProperty(t,"prototype",{writable:!1}),t;var t,e,i}();return g(It.prototype,t,i,e,St,jt,At),It});
Index: src/assets/vendor/lenis-LICENSE.txt
===================================================================
--- src/assets/vendor/lenis-LICENSE.txt	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,9 +1,0 @@
-The MIT License
-
-Copyright (c) 2024 darkroom.engineering
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Index: src/assets/vendor/lenis-snap.mjs
===================================================================
--- src/assets/vendor/lenis-snap.mjs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,334 +1,0 @@
-//#region packages/snap/src/debounce.ts
-function debounce(callback, delay) {
-	let timer;
-	return function(...args) {
-		clearTimeout(timer);
-		timer = setTimeout(() => {
-			timer = void 0;
-			callback.apply(this, args);
-		}, delay);
-	};
-}
-//#endregion
-//#region packages/snap/src/element.ts
-function removeParentSticky(element) {
-	if (getComputedStyle(element).position === "sticky") {
-		element.style.setProperty("position", "static");
-		element.dataset.sticky = "true";
-	}
-	if (element.offsetParent) removeParentSticky(element.offsetParent);
-}
-function addParentSticky(element) {
-	if (element?.dataset?.sticky === "true") {
-		element.style.removeProperty("position");
-		delete element.dataset.sticky;
-	}
-	if (element.offsetParent) addParentSticky(element.offsetParent);
-}
-function offsetTop(element, accumulator = 0) {
-	const top = accumulator + element.offsetTop;
-	if (element.offsetParent) return offsetTop(element.offsetParent, top);
-	return top;
-}
-function offsetLeft(element, accumulator = 0) {
-	const left = accumulator + element.offsetLeft;
-	if (element.offsetParent) return offsetLeft(element.offsetParent, left);
-	return left;
-}
-function scrollTop(element, accumulator = 0) {
-	const top = accumulator + element.scrollTop;
-	if (element.offsetParent) return scrollTop(element.offsetParent, top);
-	return top + window.scrollY;
-}
-function scrollLeft(element, accumulator = 0) {
-	const left = accumulator + element.scrollLeft;
-	if (element.offsetParent) return scrollLeft(element.offsetParent, left);
-	return left + window.scrollX;
-}
-var SnapElement = class {
-	element;
-	options;
-	align;
-	rect = {};
-	wrapperResizeObserver;
-	resizeObserver;
-	debouncedWrapperResize;
-	constructor(element, { align = ["start"], ignoreSticky = true, ignoreTransform = false } = {}) {
-		this.element = element;
-		this.options = {
-			align,
-			ignoreSticky,
-			ignoreTransform
-		};
-		this.align = [align].flat();
-		this.debouncedWrapperResize = debounce(this.onWrapperResize, 500);
-		this.wrapperResizeObserver = new ResizeObserver(this.debouncedWrapperResize);
-		this.wrapperResizeObserver.observe(document.body);
-		this.onWrapperResize();
-		this.resizeObserver = new ResizeObserver(this.onResize);
-		this.resizeObserver.observe(this.element);
-		this.setRect({
-			width: this.element.offsetWidth,
-			height: this.element.offsetHeight
-		});
-	}
-	destroy() {
-		this.wrapperResizeObserver.disconnect();
-		this.resizeObserver.disconnect();
-	}
-	setRect({ top, left, width, height, element } = {}) {
-		top = top ?? this.rect.top;
-		left = left ?? this.rect.left;
-		width = width ?? this.rect.width;
-		height = height ?? this.rect.height;
-		element = element ?? this.rect.element;
-		if (top === this.rect.top && left === this.rect.left && width === this.rect.width && height === this.rect.height && element === this.rect.element) return;
-		this.rect.top = top;
-		this.rect.y = top;
-		this.rect.width = width;
-		this.rect.height = height;
-		this.rect.left = left;
-		this.rect.x = left;
-		this.rect.bottom = top + height;
-		this.rect.right = left + width;
-	}
-	onWrapperResize = () => {
-		let top;
-		let left;
-		if (this.options.ignoreSticky) removeParentSticky(this.element);
-		if (this.options.ignoreTransform) {
-			top = offsetTop(this.element);
-			left = offsetLeft(this.element);
-		} else {
-			const rect = this.element.getBoundingClientRect();
-			top = rect.top + scrollTop(this.element);
-			left = rect.left + scrollLeft(this.element);
-		}
-		if (this.options.ignoreSticky) addParentSticky(this.element);
-		this.setRect({
-			top,
-			left
-		});
-	};
-	onResize = ([entry]) => {
-		if (!entry?.borderBoxSize[0]) return;
-		const width = entry.borderBoxSize[0].inlineSize;
-		const height = entry.borderBoxSize[0].blockSize;
-		this.setRect({
-			width,
-			height
-		});
-	};
-};
-//#endregion
-//#region packages/snap/src/uid.ts
-let index = 0;
-function uid() {
-	return index++;
-}
-//#endregion
-//#region packages/snap/src/snap.ts
-/**
-* Snap class to handle the snap functionality
-*
-* @example
-* const snap = new Snap(lenis, {
-*   type: 'mandatory', // 'mandatory', 'proximity' or 'lock'
-*   onSnapStart: (snap) => {
-*     console.log('onSnapStart', snap)
-*   },
-*   onSnapComplete: (snap) => {
-*     console.log('onSnapComplete', snap)
-*   },
-* })
-*
-* snap.add(500) // snap at 500px
-*
-* const removeSnap = snap.add(500)
-*
-* if (someCondition) {
-*   removeSnap()
-* }
-*/
-var Snap = class {
-	options;
-	elements = /* @__PURE__ */ new Map();
-	snaps = /* @__PURE__ */ new Map();
-	viewport = {
-		width: window.innerWidth,
-		height: window.innerHeight
-	};
-	isStopped = false;
-	onSnapDebounced;
-	currentSnapIndex;
-	constructor(lenis, { type = "proximity", lerp, easing, duration, distanceThreshold = "50%", debounce: debounceDelay = 500, onSnapStart, onSnapComplete } = {}) {
-		this.lenis = lenis;
-		if (!window.lenis) window.lenis = {};
-		window.lenis.snap = true;
-		this.options = {
-			type,
-			lerp,
-			easing,
-			duration,
-			distanceThreshold,
-			debounce: debounceDelay,
-			onSnapStart,
-			onSnapComplete
-		};
-		this.onWindowResize();
-		window.addEventListener("resize", this.onWindowResize);
-		this.onSnapDebounced = debounce(this.onSnap, this.options.debounce);
-		this.lenis.on("virtual-scroll", this.onSnapDebounced);
-	}
-	/**
-	* Destroy the snap instance
-	*/
-	destroy() {
-		this.lenis.off("virtual-scroll", this.onSnapDebounced);
-		window.removeEventListener("resize", this.onWindowResize);
-		this.elements.forEach((element) => {
-			element.destroy();
-		});
-	}
-	/**
-	* Start the snap after it has been stopped
-	*/
-	start() {
-		this.isStopped = false;
-	}
-	/**
-	* Stop the snap
-	*/
-	stop() {
-		this.isStopped = true;
-	}
-	/**
-	* Add a snap to the snap instance
-	*
-	* @param value The value to snap to
-	* @param userData User data that will be forwarded through the snap event
-	* @returns Unsubscribe function
-	*/
-	add(value) {
-		const id = uid();
-		this.snaps.set(id, { value });
-		return () => this.snaps.delete(id);
-	}
-	/**
-	* Add an element to the snap instance
-	*
-	* @param element The element to add
-	* @param options The options for the element
-	* @returns Unsubscribe function
-	*/
-	addElement(element, options = {}) {
-		const id = uid();
-		this.elements.set(id, new SnapElement(element, options));
-		return () => this.elements.delete(id);
-	}
-	addElements(elements, options = {}) {
-		const map = [...elements].map((element) => this.addElement(element, options));
-		return () => {
-			map.forEach((remove) => {
-				remove();
-			});
-		};
-	}
-	onWindowResize = () => {
-		this.viewport.width = window.innerWidth;
-		this.viewport.height = window.innerHeight;
-	};
-	computeSnaps = () => {
-		const { isHorizontal } = this.lenis;
-		let snaps = [...this.snaps.values()];
-		this.elements.forEach(({ rect, align }) => {
-			let value;
-			align.forEach((align) => {
-				if (align === "start") value = rect.top;
-				else if (align === "center") value = isHorizontal ? rect.left + rect.width / 2 - this.viewport.width / 2 : rect.top + rect.height / 2 - this.viewport.height / 2;
-				else if (align === "end") value = isHorizontal ? rect.left + rect.width - this.viewport.width : rect.top + rect.height - this.viewport.height;
-				if (typeof value === "number") snaps.push({ value: Math.ceil(value) });
-			});
-		});
-		snaps = snaps.sort((a, b) => Math.abs(a.value) - Math.abs(b.value));
-		return snaps;
-	};
-	previous() {
-		this.goTo((this.currentSnapIndex ?? 0) - 1);
-	}
-	next() {
-		this.goTo((this.currentSnapIndex ?? 0) + 1);
-	}
-	goTo(index) {
-		const snaps = this.computeSnaps();
-		if (snaps.length === 0) return;
-		this.currentSnapIndex = Math.max(0, Math.min(index, snaps.length - 1));
-		const currentSnap = snaps[this.currentSnapIndex];
-		if (currentSnap === void 0) return;
-		this.lenis.scrollTo(currentSnap.value, {
-			duration: this.options.duration,
-			easing: this.options.easing,
-			lerp: this.options.lerp,
-			lock: this.options.type === "lock",
-			userData: { initiator: "snap" },
-			onStart: () => {
-				this.options.onSnapStart?.({
-					index: this.currentSnapIndex,
-					...currentSnap
-				});
-			},
-			onComplete: () => {
-				this.options.onSnapComplete?.({
-					index: this.currentSnapIndex,
-					...currentSnap
-				});
-			}
-		});
-	}
-	get distanceThreshold() {
-		let distanceThreshold = Number.POSITIVE_INFINITY;
-		if (this.options.type === "mandatory") return Number.POSITIVE_INFINITY;
-		const { isHorizontal } = this.lenis;
-		const axis = isHorizontal ? "width" : "height";
-		if (typeof this.options.distanceThreshold === "string" && this.options.distanceThreshold.endsWith("%")) distanceThreshold = Number(this.options.distanceThreshold.replace("%", "")) / 100 * this.viewport[axis];
-		else if (typeof this.options.distanceThreshold === "number") distanceThreshold = this.options.distanceThreshold;
-		else distanceThreshold = this.viewport[axis];
-		return distanceThreshold;
-	}
-	onSnap = (e) => {
-		if (this.isStopped) return;
-		if (e.event.type === "touchmove") return;
-		if (this.options.type === "lock" && this.lenis.userData?.initiator === "snap") return;
-		let { scroll, isHorizontal } = this.lenis;
-		const delta = isHorizontal ? e.deltaX : e.deltaY;
-		scroll = Math.ceil(this.lenis.scroll + delta);
-		const snaps = this.computeSnaps();
-		if (snaps.length === 0) return;
-		let snapIndex;
-		const prevSnapIndex = snaps.findLastIndex(({ value }) => value < scroll);
-		const nextSnapIndex = snaps.findIndex(({ value }) => value > scroll);
-		if (this.options.type === "lock") {
-			if (delta > 0) snapIndex = nextSnapIndex;
-			else if (delta < 0) snapIndex = prevSnapIndex;
-		} else {
-			const prevSnap = snaps[prevSnapIndex];
-			const distanceToPrevSnap = prevSnap ? Math.abs(scroll - prevSnap.value) : Number.POSITIVE_INFINITY;
-			const nextSnap = snaps[nextSnapIndex];
-			snapIndex = distanceToPrevSnap < (nextSnap ? Math.abs(scroll - nextSnap.value) : Number.POSITIVE_INFINITY) ? prevSnapIndex : nextSnapIndex;
-		}
-		if (snapIndex === void 0) return;
-		if (snapIndex === -1) return;
-		snapIndex = Math.max(0, Math.min(snapIndex, snaps.length - 1));
-		const snap = snaps[snapIndex];
-		if (Math.abs(scroll - snap.value) <= this.distanceThreshold) this.goTo(snapIndex);
-	};
-	resize() {
-		this.elements.forEach((element) => {
-			element.onWrapperResize();
-		});
-	}
-};
-//#endregion
-export { Snap as default };
-
-//# sourceMappingURL=lenis-snap.mjs.map
Index: src/assets/vendor/lenis.mjs
===================================================================
--- src/assets/vendor/lenis.mjs	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,1057 +1,0 @@
-//#region package.json
-var version = "1.3.26";
-//#endregion
-//#region packages/core/src/maths.ts
-/**
-* Clamp a value between a minimum and maximum value
-*
-* @param min Minimum value
-* @param input Value to clamp
-* @param max Maximum value
-* @returns Clamped value
-*/
-function clamp(min, input, max) {
-	return Math.max(min, Math.min(input, max));
-}
-/**
-*  Linearly interpolate between two values using an amount (0 <= t <= 1)
-*
-* @param x First value
-* @param y Second value
-* @param t Amount to interpolate (0 <= t <= 1)
-* @returns Interpolated value
-*/
-function lerp(x, y, t) {
-	return (1 - t) * x + t * y;
-}
-/**
-* Damp a value over time using a damping factor
-* {@link http://www.rorydriscoll.com/2016/03/07/frame-rate-independent-damping-using-lerp/}
-*
-* @param x Initial value
-* @param y Target value
-* @param lambda Damping factor
-* @param dt Time elapsed since the last update
-* @returns Damped value
-*/
-function damp(x, y, lambda, deltaTime) {
-	return lerp(x, y, 1 - Math.exp(-lambda * deltaTime));
-}
-/**
-* Calculate the modulo of the dividend and divisor while keeping the result within the same sign as the divisor
-* {@link https://anguscroll.com/just/just-modulo}
-*
-* @param n Dividend
-* @param d Divisor
-* @returns Modulo
-*/
-function modulo(n, d) {
-	return (n % d + d) % d;
-}
-//#endregion
-//#region packages/core/src/animate.ts
-/**
-* Animate class to handle value animations with lerping or easing
-*
-* @example
-* const animate = new Animate()
-* animate.fromTo(0, 100, { duration: 1, easing: (t) => t })
-* animate.advance(0.5) // 50
-*/
-var Animate = class {
-	isRunning = false;
-	value = 0;
-	from = 0;
-	to = 0;
-	currentTime = 0;
-	lerp;
-	duration;
-	easing;
-	onUpdate;
-	/**
-	* Advance the animation by the given delta time
-	*
-	* @param deltaTime - The time in seconds to advance the animation
-	*/
-	advance(deltaTime) {
-		if (!this.isRunning) return;
-		let completed = false;
-		if (this.duration && this.easing) {
-			this.currentTime += deltaTime;
-			const linearProgress = clamp(0, this.currentTime / this.duration, 1);
-			completed = linearProgress >= 1;
-			const easedProgress = completed ? 1 : this.easing(linearProgress);
-			this.value = this.from + (this.to - this.from) * easedProgress;
-		} else if (this.lerp) {
-			this.value = damp(this.value, this.to, this.lerp * 60, deltaTime);
-			if (Math.round(this.value) === Math.round(this.to)) {
-				this.value = this.to;
-				completed = true;
-			}
-		} else {
-			this.value = this.to;
-			completed = true;
-		}
-		if (completed) this.stop();
-		this.onUpdate?.(this.value, completed);
-	}
-	/** Stop the animation */
-	stop() {
-		this.isRunning = false;
-	}
-	/**
-	* Set up the animation from a starting value to an ending value
-	* with optional parameters for lerping, duration, easing, and onUpdate callback
-	*
-	* @param from - The starting value
-	* @param to - The ending value
-	* @param options - Options for the animation
-	*/
-	fromTo(from, to, { lerp, duration, easing, onStart, onUpdate }) {
-		this.from = this.value = from;
-		this.to = to;
-		this.lerp = lerp;
-		this.duration = duration;
-		this.easing = easing;
-		this.currentTime = 0;
-		this.isRunning = true;
-		onStart?.();
-		this.onUpdate = onUpdate;
-	}
-};
-//#endregion
-//#region packages/core/src/debounce.ts
-function debounce(callback, delay) {
-	let timer;
-	return function(...args) {
-		clearTimeout(timer);
-		timer = setTimeout(() => {
-			timer = void 0;
-			callback.apply(this, args);
-		}, delay);
-	};
-}
-//#endregion
-//#region packages/core/src/dimensions.ts
-/**
-* Dimensions class to handle the size of the content and wrapper
-*
-* @example
-* const dimensions = new Dimensions(wrapper, content)
-* dimensions.on('resize', (e) => {
-*   console.log(e.width, e.height)
-* })
-*/
-var Dimensions = class {
-	width = 0;
-	height = 0;
-	scrollHeight = 0;
-	scrollWidth = 0;
-	debouncedResize;
-	wrapperResizeObserver;
-	contentResizeObserver;
-	constructor(wrapper, content, { autoResize = true, debounce: debounceValue = 250 } = {}) {
-		this.wrapper = wrapper;
-		this.content = content;
-		if (autoResize) {
-			this.debouncedResize = debounce(this.resize, debounceValue);
-			if (this.wrapper instanceof Window) window.addEventListener("resize", this.debouncedResize);
-			else {
-				this.wrapperResizeObserver = new ResizeObserver(this.debouncedResize);
-				this.wrapperResizeObserver.observe(this.wrapper);
-			}
-			this.contentResizeObserver = new ResizeObserver(this.debouncedResize);
-			this.contentResizeObserver.observe(this.content);
-		}
-		this.resize();
-	}
-	destroy() {
-		this.wrapperResizeObserver?.disconnect();
-		this.contentResizeObserver?.disconnect();
-		if (this.wrapper === window && this.debouncedResize) window.removeEventListener("resize", this.debouncedResize);
-	}
-	resize = () => {
-		this.onWrapperResize();
-		this.onContentResize();
-	};
-	onWrapperResize = () => {
-		if (this.wrapper instanceof Window) {
-			this.width = window.innerWidth;
-			this.height = window.innerHeight;
-		} else {
-			this.width = this.wrapper.clientWidth;
-			this.height = this.wrapper.clientHeight;
-		}
-	};
-	onContentResize = () => {
-		if (this.wrapper instanceof Window) {
-			this.scrollHeight = this.content.scrollHeight;
-			this.scrollWidth = this.content.scrollWidth;
-		} else {
-			this.scrollHeight = this.wrapper.scrollHeight;
-			this.scrollWidth = this.wrapper.scrollWidth;
-		}
-	};
-	get limit() {
-		return {
-			x: this.scrollWidth - this.width,
-			y: this.scrollHeight - this.height
-		};
-	}
-};
-//#endregion
-//#region packages/core/src/emitter.ts
-/**
-* Emitter class to handle events
-* @example
-* const emitter = new Emitter()
-* emitter.on('event', (data) => {
-*   console.log(data)
-* })
-* emitter.emit('event', 'data')
-*/
-var Emitter = class {
-	events = {};
-	/**
-	* Emit an event with the given data
-	* @param event Event name
-	* @param args Data to pass to the event handlers
-	*/
-	emit(event, ...args) {
-		const callbacks = this.events[event] || [];
-		for (let i = 0, length = callbacks.length; i < length; i++) callbacks[i]?.(...args);
-	}
-	/**
-	* Add a callback to the event
-	* @param event Event name
-	* @param cb Callback function
-	* @returns Unsubscribe function
-	*/
-	on(event, cb) {
-		if (this.events[event]) this.events[event].push(cb);
-		else this.events[event] = [cb];
-		return () => {
-			this.events[event] = this.events[event]?.filter((i) => cb !== i);
-		};
-	}
-	/**
-	* Remove a callback from the event
-	* @param event Event name
-	* @param callback Callback function
-	*/
-	off(event, callback) {
-		this.events[event] = this.events[event]?.filter((i) => callback !== i);
-	}
-	/**
-	* Remove all event listeners and clean up
-	*/
-	destroy() {
-		this.events = {};
-	}
-};
-//#endregion
-//#region packages/core/src/virtual-scroll.ts
-const LINE_HEIGHT = 100 / 6;
-const listenerOptions = { passive: false };
-function getDeltaMultiplier(deltaMode, size) {
-	if (deltaMode === 1) return LINE_HEIGHT;
-	if (deltaMode === 2) return size;
-	return 1;
-}
-var VirtualScroll = class {
-	touchStart = {
-		x: 0,
-		y: 0
-	};
-	lastDelta = {
-		x: 0,
-		y: 0
-	};
-	window = {
-		width: 0,
-		height: 0
-	};
-	emitter = new Emitter();
-	constructor(element, options = {
-		wheelMultiplier: 1,
-		touchMultiplier: 1
-	}) {
-		this.element = element;
-		this.options = options;
-		window.addEventListener("resize", this.onWindowResize);
-		this.onWindowResize();
-		this.element.addEventListener("wheel", this.onWheel, listenerOptions);
-		this.element.addEventListener("touchstart", this.onTouchStart, listenerOptions);
-		this.element.addEventListener("touchmove", this.onTouchMove, listenerOptions);
-		this.element.addEventListener("touchend", this.onTouchEnd, listenerOptions);
-	}
-	/**
-	* Add an event listener for the given event and callback
-	*
-	* @param event Event name
-	* @param callback Callback function
-	*/
-	on(event, callback) {
-		return this.emitter.on(event, callback);
-	}
-	/** Remove all event listeners and clean up */
-	destroy() {
-		this.emitter.destroy();
-		window.removeEventListener("resize", this.onWindowResize);
-		this.element.removeEventListener("wheel", this.onWheel, listenerOptions);
-		this.element.removeEventListener("touchstart", this.onTouchStart, listenerOptions);
-		this.element.removeEventListener("touchmove", this.onTouchMove, listenerOptions);
-		this.element.removeEventListener("touchend", this.onTouchEnd, listenerOptions);
-	}
-	/**
-	* Event handler for 'touchstart' event
-	*
-	* @param event Touch event
-	*/
-	onTouchStart = (event) => {
-		const { clientX, clientY } = event.targetTouches ? event.targetTouches[0] : event;
-		this.touchStart.x = clientX;
-		this.touchStart.y = clientY;
-		this.lastDelta = {
-			x: 0,
-			y: 0
-		};
-		this.emitter.emit("scroll", {
-			deltaX: 0,
-			deltaY: 0,
-			event
-		});
-	};
-	/** Event handler for 'touchmove' event */
-	onTouchMove = (event) => {
-		const { clientX, clientY } = event.targetTouches ? event.targetTouches[0] : event;
-		const deltaX = -(clientX - this.touchStart.x) * this.options.touchMultiplier;
-		const deltaY = -(clientY - this.touchStart.y) * this.options.touchMultiplier;
-		this.touchStart.x = clientX;
-		this.touchStart.y = clientY;
-		this.lastDelta = {
-			x: deltaX,
-			y: deltaY
-		};
-		this.emitter.emit("scroll", {
-			deltaX,
-			deltaY,
-			event
-		});
-	};
-	onTouchEnd = (event) => {
-		this.emitter.emit("scroll", {
-			deltaX: this.lastDelta.x,
-			deltaY: this.lastDelta.y,
-			event
-		});
-	};
-	/** Event handler for 'wheel' event */
-	onWheel = (event) => {
-		let { deltaX, deltaY, deltaMode } = event;
-		const multiplierX = getDeltaMultiplier(deltaMode, this.window.width);
-		const multiplierY = getDeltaMultiplier(deltaMode, this.window.height);
-		deltaX *= multiplierX;
-		deltaY *= multiplierY;
-		deltaX *= this.options.wheelMultiplier;
-		deltaY *= this.options.wheelMultiplier;
-		this.emitter.emit("scroll", {
-			deltaX,
-			deltaY,
-			event
-		});
-	};
-	onWindowResize = () => {
-		this.window = {
-			width: window.innerWidth,
-			height: window.innerHeight
-		};
-	};
-};
-//#endregion
-//#region packages/core/src/lenis.ts
-const defaultEasing = (t) => Math.min(1, 1.001 - 2 ** (-10 * t));
-var Lenis = class {
-	_isScrolling = false;
-	_isStopped = false;
-	_isLocked = false;
-	_preventNextNativeScrollEvent = false;
-	_resetVelocityTimeout = null;
-	_rafId = null;
-	_isDraggingSelection = false;
-	reducedMotionMediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
-	/**
-	* Whether or not the user is touching the screen
-	*/
-	isTouching;
-	/**
-	* Whether or not the device is running iOS
-	*/
-	isIos;
-	/**
-	* The time in ms since the lenis instance was created
-	*/
-	time = 0;
-	/**
-	* User data that will be forwarded through the scroll event
-	*
-	* @example
-	* lenis.scrollTo(100, {
-	*   userData: {
-	*     foo: 'bar'
-	*   }
-	* })
-	*/
-	userData = {};
-	/**
-	* The last velocity of the scroll
-	*/
-	lastVelocity = 0;
-	/**
-	* The current velocity of the scroll
-	*/
-	velocity = 0;
-	/**
-	* The direction of the scroll
-	*/
-	direction = 0;
-	/**
-	* The options passed to the lenis instance
-	*/
-	options;
-	/**
-	* The target scroll value
-	*/
-	targetScroll;
-	/**
-	* The animated scroll value
-	*/
-	animatedScroll;
-	animate = new Animate();
-	emitter = new Emitter();
-	dimensions;
-	virtualScroll;
-	constructor({ wrapper = window, content = document.documentElement, eventsTarget = wrapper, smoothWheel = true, syncTouch = false, syncTouchLerp = .075, touchInertiaExponent = 1.7, duration, easing, lerp = .1, infinite = false, orientation = "vertical", gestureOrientation = orientation === "horizontal" ? "both" : "vertical", touchMultiplier = 1, wheelMultiplier = 1, autoResize = true, prevent, virtualScroll, overscroll = true, autoRaf = false, anchors = false, autoToggle = false, allowNestedScroll = false, __experimental__naiveDimensions = false, naiveDimensions = __experimental__naiveDimensions, stopInertiaOnNavigate = false, respectReducedMotion = true } = {}) {
-		window.lenisVersion = version;
-		if (!window.lenis) window.lenis = {};
-		window.lenis.version = version;
-		if (orientation === "horizontal") window.lenis.horizontal = true;
-		if (syncTouch === true) window.lenis.touch = true;
-		this.isIos = /(iPad|iPhone|iPod)/g.test(navigator.userAgent);
-		if (!wrapper || wrapper === document.documentElement) wrapper = window;
-		if (typeof duration === "number" && typeof easing !== "function") easing = defaultEasing;
-		else if (typeof easing === "function" && typeof duration !== "number") duration = 1;
-		this.options = {
-			wrapper,
-			content,
-			eventsTarget,
-			smoothWheel,
-			syncTouch,
-			syncTouchLerp,
-			touchInertiaExponent,
-			duration,
-			easing,
-			lerp,
-			infinite,
-			gestureOrientation,
-			orientation,
-			touchMultiplier,
-			wheelMultiplier,
-			autoResize,
-			prevent,
-			virtualScroll,
-			overscroll,
-			autoRaf,
-			anchors,
-			autoToggle,
-			allowNestedScroll,
-			naiveDimensions,
-			stopInertiaOnNavigate,
-			respectReducedMotion
-		};
-		this.dimensions = new Dimensions(wrapper, content, { autoResize });
-		this.updateClassName();
-		this.targetScroll = this.animatedScroll = this.actualScroll;
-		this.options.wrapper.addEventListener("scroll", this.onNativeScroll);
-		this.options.wrapper.addEventListener("scrollend", this.onScrollEnd, { capture: true });
-		if (this.options.anchors || this.options.stopInertiaOnNavigate) this.options.wrapper.addEventListener("click", this.onClick);
-		this.options.wrapper.addEventListener("pointerdown", this.onPointerDown);
-		this.virtualScroll = new VirtualScroll(eventsTarget, {
-			touchMultiplier,
-			wheelMultiplier
-		});
-		this.virtualScroll.on("scroll", this.onVirtualScroll);
-		if (this.options.autoToggle) {
-			this.checkOverflow();
-			this.rootElement.addEventListener("transitionend", this.onTransitionEnd);
-		}
-		if (this.options.autoRaf) this._rafId = requestAnimationFrame(this.raf);
-	}
-	/**
-	* Destroy the lenis instance, remove all event listeners and clean up the class name
-	*/
-	destroy() {
-		this.emitter.destroy();
-		this.options.wrapper.removeEventListener("scroll", this.onNativeScroll);
-		this.options.wrapper.removeEventListener("scrollend", this.onScrollEnd, { capture: true });
-		this.options.wrapper.removeEventListener("pointerdown", this.onPointerDown);
-		if (this.options.anchors || this.options.stopInertiaOnNavigate) this.options.wrapper.removeEventListener("click", this.onClick);
-		this.virtualScroll.destroy();
-		this.dimensions.destroy();
-		this.cleanUpClassName();
-		if (this._rafId) cancelAnimationFrame(this._rafId);
-	}
-	on(event, callback) {
-		return this.emitter.on(event, callback);
-	}
-	off(event, callback) {
-		return this.emitter.off(event, callback);
-	}
-	onScrollEnd = (e) => {
-		if (!(e instanceof CustomEvent)) {
-			if (this.isScrolling === "smooth" || this.isScrolling === false) e.stopPropagation();
-		}
-	};
-	dispatchScrollendEvent = () => {
-		this.options.wrapper.dispatchEvent(new CustomEvent("scrollend", {
-			bubbles: this.options.wrapper === window,
-			detail: { lenisScrollEnd: true }
-		}));
-	};
-	get overflow() {
-		const property = this.isHorizontal ? "overflow-x" : "overflow-y";
-		return getComputedStyle(this.rootElement)[property];
-	}
-	checkOverflow() {
-		if (["hidden", "clip"].includes(this.overflow)) this.internalStop();
-		else this.internalStart();
-	}
-	onTransitionEnd = (event) => {
-		if (event.propertyName?.includes("overflow") && event.target === this.rootElement) this.checkOverflow();
-	};
-	setScroll(scroll) {
-		if (this.isHorizontal) this.options.wrapper.scrollTo({
-			left: scroll,
-			behavior: "instant"
-		});
-		else this.options.wrapper.scrollTo({
-			top: scroll,
-			behavior: "instant"
-		});
-	}
-	onClick = (event) => {
-		const linkElementsUrls = event.composedPath().filter((node) => node instanceof HTMLAnchorElement && node.href).map((element) => new URL(element.href));
-		const currentUrl = new URL(window.location.href);
-		if (this.options.anchors) {
-			const anchorElementUrl = linkElementsUrls.find((targetUrl) => currentUrl.host === targetUrl.host && currentUrl.pathname === targetUrl.pathname && targetUrl.hash);
-			if (anchorElementUrl) {
-				const options = typeof this.options.anchors === "object" && this.options.anchors ? this.options.anchors : void 0;
-				const target = decodeURIComponent(anchorElementUrl.hash);
-				this.scrollTo(target, options);
-				return;
-			}
-		}
-		if (this.options.stopInertiaOnNavigate) {
-			if (linkElementsUrls.some((targetUrl) => currentUrl.host === targetUrl.host && currentUrl.pathname !== targetUrl.pathname)) {
-				this.reset();
-				return;
-			}
-		}
-	};
-	onPointerDown = (event) => {
-		if (event.button === 1) this.reset();
-	};
-	isTouchOnSelectionHandle(event) {
-		const selection = window.getSelection();
-		if (!selection || selection.isCollapsed || selection.rangeCount === 0) return false;
-		const touch = event.targetTouches[0] ?? event.changedTouches[0];
-		if (!touch) return false;
-		const rects = selection.getRangeAt(0).getClientRects();
-		if (rects.length === 0) return false;
-		const first = rects[0];
-		const last = rects[rects.length - 1];
-		const HANDLE_RADIUS = 40;
-		const nearStart = Math.hypot(touch.clientX - first.left, touch.clientY - first.top) <= HANDLE_RADIUS;
-		const nearEnd = Math.hypot(touch.clientX - last.right, touch.clientY - last.bottom) <= HANDLE_RADIUS;
-		return nearStart || nearEnd;
-	}
-	onVirtualScroll = (data) => {
-		if (typeof this.options.virtualScroll === "function" && this.options.virtualScroll(data) === false) return;
-		const { deltaX, deltaY, event } = data;
-		this.emitter.emit("virtual-scroll", {
-			deltaX,
-			deltaY,
-			event
-		});
-		if (event.ctrlKey) return;
-		if (event.lenisStopPropagation) return;
-		const isTouch = event.type.includes("touch");
-		const isWheel = event.type.includes("wheel");
-		if (isTouch && this.isIos) {
-			if (event.type === "touchstart") this._isDraggingSelection = this.isTouchOnSelectionHandle(event);
-			if (this._isDraggingSelection) {
-				if (event.type === "touchend") this._isDraggingSelection = false;
-				return;
-			}
-		}
-		this.isTouching = event.type === "touchstart" || event.type === "touchmove";
-		const isClickOrTap = deltaX === 0 && deltaY === 0;
-		if (this.options.syncTouch && isTouch && event.type === "touchstart" && isClickOrTap && !this.isStopped && !this.isLocked) {
-			this.reset();
-			return;
-		}
-		const isUnknownGesture = this.options.gestureOrientation === "vertical" && deltaY === 0 || this.options.gestureOrientation === "horizontal" && deltaX === 0;
-		if (isClickOrTap || isUnknownGesture) return;
-		let composedPath = event.composedPath();
-		composedPath = composedPath.slice(0, composedPath.indexOf(this.rootElement));
-		const prevent = this.options.prevent;
-		const gestureOrientation = Math.abs(deltaX) >= Math.abs(deltaY) ? "horizontal" : "vertical";
-		if (composedPath.find((node) => node instanceof HTMLElement && (typeof prevent === "function" && prevent?.(node) || node.hasAttribute?.("data-lenis-prevent") || gestureOrientation === "vertical" && node.hasAttribute?.("data-lenis-prevent-vertical") || gestureOrientation === "horizontal" && node.hasAttribute?.("data-lenis-prevent-horizontal") || isTouch && node.hasAttribute?.("data-lenis-prevent-touch") || isWheel && node.hasAttribute?.("data-lenis-prevent-wheel") || this.options.allowNestedScroll && this.hasNestedScroll(node, {
-			deltaX,
-			deltaY
-		})))) return;
-		if (this.isStopped || this.isLocked) {
-			if (event.cancelable) event.preventDefault();
-			return;
-		}
-		if (!(this.options.syncTouch && isTouch || this.options.smoothWheel && isWheel)) {
-			this.isScrolling = "native";
-			this.animate.stop();
-			event.lenisStopPropagation = true;
-			return;
-		}
-		let delta = deltaY;
-		if (this.options.gestureOrientation === "both") delta = Math.abs(deltaY) > Math.abs(deltaX) ? deltaY : deltaX;
-		else if (this.options.gestureOrientation === "horizontal") delta = deltaX;
-		if (!this.options.overscroll || this.options.infinite || this.options.wrapper !== window && this.limit > 0 && (this.animatedScroll > 0 && this.animatedScroll < this.limit || this.animatedScroll === 0 && deltaY > 0 || this.animatedScroll === this.limit && deltaY < 0)) event.lenisStopPropagation = true;
-		if (event.cancelable) event.preventDefault();
-		const isSyncTouch = isTouch && this.options.syncTouch;
-		const hasTouchInertia = isTouch && event.type === "touchend";
-		if (hasTouchInertia) delta = Math.sign(delta) * Math.abs(this.velocity) ** this.options.touchInertiaExponent;
-		this.scrollTo(this.targetScroll + delta, {
-			programmatic: false,
-			...isSyncTouch ? { lerp: hasTouchInertia ? this.options.syncTouchLerp : 1 } : {
-				lerp: this.options.lerp,
-				duration: this.options.duration,
-				easing: this.options.easing
-			}
-		});
-	};
-	/**
-	* Force lenis to recalculate the dimensions
-	*/
-	resize() {
-		this.dimensions.resize();
-		this.animatedScroll = this.targetScroll = this.actualScroll;
-		this.emit();
-	}
-	emit() {
-		this.emitter.emit("scroll", this);
-	}
-	onNativeScroll = () => {
-		if (this._resetVelocityTimeout !== null) {
-			clearTimeout(this._resetVelocityTimeout);
-			this._resetVelocityTimeout = null;
-		}
-		if (this._preventNextNativeScrollEvent) {
-			this._preventNextNativeScrollEvent = false;
-			return;
-		}
-		if (this.isScrolling === false || this.isScrolling === "native") {
-			const lastScroll = this.animatedScroll;
-			this.animatedScroll = this.targetScroll = this.actualScroll;
-			this.lastVelocity = this.velocity;
-			this.velocity = this.animatedScroll - lastScroll;
-			this.direction = Math.sign(this.animatedScroll - lastScroll);
-			if (!this.isStopped) this.isScrolling = "native";
-			this.emit();
-			if (this.velocity !== 0) this._resetVelocityTimeout = setTimeout(() => {
-				this.lastVelocity = this.velocity;
-				this.velocity = 0;
-				this.isScrolling = false;
-				this.emit();
-			}, 400);
-		}
-	};
-	reset() {
-		this.isLocked = false;
-		this.isScrolling = false;
-		this.animatedScroll = this.targetScroll = this.actualScroll;
-		this.lastVelocity = this.velocity = 0;
-		this.animate.stop();
-	}
-	/**
-	* Start lenis scroll after it has been stopped
-	*/
-	start() {
-		if (!this.isStopped) return;
-		if (this.options.autoToggle) {
-			this.rootElement.style.removeProperty("overflow");
-			return;
-		}
-		this.internalStart();
-	}
-	internalStart() {
-		if (!this.isStopped) return;
-		this.reset();
-		this.isStopped = false;
-		this.emit();
-	}
-	/**
-	* Stop lenis scroll
-	*/
-	stop() {
-		if (this.isStopped) return;
-		if (this.options.autoToggle) {
-			this.rootElement.style.setProperty("overflow", "clip");
-			return;
-		}
-		this.internalStop();
-	}
-	internalStop() {
-		if (this.isStopped) return;
-		this.reset();
-		this.isStopped = true;
-		this.emit();
-	}
-	/**
-	* RequestAnimationFrame for lenis
-	*
-	* @param time The time in ms from an external clock like `requestAnimationFrame` or Tempus
-	*/
-	raf = (time) => {
-		const deltaTime = time - (this.time || time);
-		this.time = time;
-		this.animate.advance(deltaTime * .001);
-		if (this.options.autoRaf) this._rafId = requestAnimationFrame(this.raf);
-	};
-	/**
-	* Scroll to a target value
-	*
-	* @param target The target value to scroll to
-	* @param options The options for the scroll
-	*
-	* @example
-	* lenis.scrollTo(100, {
-	*   offset: 100,
-	*   duration: 1,
-	*   easing: (t) => 1 - Math.cos((t * Math.PI) / 2),
-	*   lerp: 0.1,
-	*   onStart: () => {
-	*     console.log('onStart')
-	*   },
-	*   onComplete: () => {
-	*     console.log('onComplete')
-	*   },
-	* })
-	*/
-	scrollTo(_target, { offset = 0, immediate = false, lock = false, programmatic = true, lerp = programmatic ? this.options.lerp : void 0, duration = programmatic ? this.options.duration : void 0, easing = programmatic ? this.options.easing : void 0, onStart, onComplete, force = false, userData } = {}) {
-		if (this.prefersReducedMotion) if (programmatic) immediate = true;
-		else {
-			lerp = 1;
-			duration = void 0;
-			easing = void 0;
-		}
-		if ((this.isStopped || this.isLocked) && !force) return;
-		let target = _target;
-		let adjustedOffset = offset;
-		if (typeof target === "string" && [
-			"top",
-			"left",
-			"start",
-			"#"
-		].includes(target)) target = 0;
-		else if (typeof target === "string" && [
-			"bottom",
-			"right",
-			"end"
-		].includes(target)) target = this.limit;
-		else {
-			let node = null;
-			if (typeof target === "string") {
-				node = target.startsWith("#") ? document.getElementById(target.slice(1)) : document.querySelector(target);
-				if (!node) if (target === "#top") target = 0;
-				else console.warn("Lenis: Target not found", target);
-			} else if (target instanceof HTMLElement && target?.nodeType) node = target;
-			if (node) {
-				if (this.options.wrapper !== window) {
-					const wrapperRect = this.rootElement.getBoundingClientRect();
-					adjustedOffset -= this.isHorizontal ? wrapperRect.left : wrapperRect.top;
-				}
-				const rect = node.getBoundingClientRect();
-				const targetStyle = getComputedStyle(node);
-				const scrollMargin = this.isHorizontal ? Number.parseFloat(targetStyle.scrollMarginLeft) : Number.parseFloat(targetStyle.scrollMarginTop);
-				const containerStyle = getComputedStyle(this.rootElement);
-				const scrollPadding = this.isHorizontal ? Number.parseFloat(containerStyle.scrollPaddingLeft) : Number.parseFloat(containerStyle.scrollPaddingTop);
-				target = (this.isHorizontal ? rect.left : rect.top) + this.animatedScroll - (Number.isNaN(scrollMargin) ? 0 : scrollMargin) - (Number.isNaN(scrollPadding) ? 0 : scrollPadding);
-			}
-		}
-		if (typeof target !== "number") return;
-		target += adjustedOffset;
-		if (this.options.infinite) {
-			if (programmatic) {
-				this.targetScroll = this.animatedScroll = this.scroll;
-				const distance = target - this.animatedScroll;
-				if (distance > this.limit / 2) target -= this.limit;
-				else if (distance < -this.limit / 2) target += this.limit;
-			}
-		} else target = clamp(0, target, this.limit);
-		if (target === this.targetScroll) {
-			onStart?.(this);
-			onComplete?.(this);
-			return;
-		}
-		this.userData = userData ?? {};
-		if (immediate) {
-			this.animatedScroll = this.targetScroll = target;
-			this.setScroll(this.scroll);
-			this.reset();
-			this.preventNextNativeScrollEvent();
-			this.emit();
-			onComplete?.(this);
-			this.userData = {};
-			requestAnimationFrame(() => {
-				this.dispatchScrollendEvent();
-			});
-			return;
-		}
-		if (!programmatic) this.targetScroll = target;
-		if (typeof duration === "number" && typeof easing !== "function") easing = defaultEasing;
-		else if (typeof easing === "function" && typeof duration !== "number") duration = 1;
-		this.animate.fromTo(this.animatedScroll, target, {
-			duration,
-			easing,
-			lerp,
-			onStart: () => {
-				if (lock) this.isLocked = true;
-				this.isScrolling = "smooth";
-				onStart?.(this);
-			},
-			onUpdate: (value, completed) => {
-				this.isScrolling = "smooth";
-				this.lastVelocity = this.velocity;
-				this.velocity = value - this.animatedScroll;
-				this.direction = Math.sign(this.velocity);
-				this.animatedScroll = value;
-				this.setScroll(this.scroll);
-				if (programmatic) this.targetScroll = value;
-				if (!completed) this.emit();
-				if (completed) {
-					this.reset();
-					this.emit();
-					onComplete?.(this);
-					this.userData = {};
-					requestAnimationFrame(() => {
-						this.dispatchScrollendEvent();
-					});
-					this.preventNextNativeScrollEvent();
-				}
-			}
-		});
-	}
-	preventNextNativeScrollEvent() {
-		this._preventNextNativeScrollEvent = true;
-		requestAnimationFrame(() => {
-			this._preventNextNativeScrollEvent = false;
-		});
-	}
-	hasNestedScroll(node, { deltaX, deltaY }) {
-		const time = Date.now();
-		if (!node._lenis) node._lenis = {};
-		const cache = node._lenis;
-		let hasOverflowX;
-		let hasOverflowY;
-		let isScrollableX;
-		let isScrollableY;
-		let hasOverscrollBehaviorX;
-		let hasOverscrollBehaviorY;
-		let scrollWidth;
-		let scrollHeight;
-		let clientWidth;
-		let clientHeight;
-		if (time - (cache.time ?? 0) > 2e3) {
-			cache.time = Date.now();
-			const computedStyle = window.getComputedStyle(node);
-			cache.computedStyle = computedStyle;
-			hasOverflowX = [
-				"auto",
-				"overlay",
-				"scroll"
-			].includes(computedStyle.overflowX);
-			hasOverflowY = [
-				"auto",
-				"overlay",
-				"scroll"
-			].includes(computedStyle.overflowY);
-			hasOverscrollBehaviorX = ["auto"].includes(computedStyle.overscrollBehaviorX);
-			hasOverscrollBehaviorY = ["auto"].includes(computedStyle.overscrollBehaviorY);
-			cache.hasOverflowX = hasOverflowX;
-			cache.hasOverflowY = hasOverflowY;
-			if (!(hasOverflowX || hasOverflowY)) return false;
-			scrollWidth = node.scrollWidth;
-			scrollHeight = node.scrollHeight;
-			clientWidth = node.clientWidth;
-			clientHeight = node.clientHeight;
-			isScrollableX = scrollWidth > clientWidth;
-			isScrollableY = scrollHeight > clientHeight;
-			cache.isScrollableX = isScrollableX;
-			cache.isScrollableY = isScrollableY;
-			cache.scrollWidth = scrollWidth;
-			cache.scrollHeight = scrollHeight;
-			cache.clientWidth = clientWidth;
-			cache.clientHeight = clientHeight;
-			cache.hasOverscrollBehaviorX = hasOverscrollBehaviorX;
-			cache.hasOverscrollBehaviorY = hasOverscrollBehaviorY;
-		} else {
-			isScrollableX = cache.isScrollableX;
-			isScrollableY = cache.isScrollableY;
-			hasOverflowX = cache.hasOverflowX;
-			hasOverflowY = cache.hasOverflowY;
-			scrollWidth = cache.scrollWidth;
-			scrollHeight = cache.scrollHeight;
-			clientWidth = cache.clientWidth;
-			clientHeight = cache.clientHeight;
-			hasOverscrollBehaviorX = cache.hasOverscrollBehaviorX;
-			hasOverscrollBehaviorY = cache.hasOverscrollBehaviorY;
-		}
-		if (!(hasOverflowX && isScrollableX || hasOverflowY && isScrollableY)) return false;
-		const orientation = Math.abs(deltaX) >= Math.abs(deltaY) ? "horizontal" : "vertical";
-		let scroll;
-		let maxScroll;
-		let delta;
-		let hasOverflow;
-		let isScrollable;
-		let hasOverscrollBehavior;
-		if (orientation === "horizontal") {
-			scroll = Math.round(node.scrollLeft);
-			maxScroll = scrollWidth - clientWidth;
-			delta = deltaX;
-			hasOverflow = hasOverflowX;
-			isScrollable = isScrollableX;
-			hasOverscrollBehavior = hasOverscrollBehaviorX;
-		} else if (orientation === "vertical") {
-			scroll = Math.round(node.scrollTop);
-			maxScroll = scrollHeight - clientHeight;
-			delta = deltaY;
-			hasOverflow = hasOverflowY;
-			isScrollable = isScrollableY;
-			hasOverscrollBehavior = hasOverscrollBehaviorY;
-		} else return false;
-		if (!hasOverscrollBehavior && (scroll >= maxScroll || scroll <= 0)) return true;
-		return (delta > 0 ? scroll < maxScroll : scroll > 0) && hasOverflow && isScrollable;
-	}
-	/**
-	* The root element on which lenis is instanced
-	*/
-	get rootElement() {
-		return this.options.wrapper === window ? document.documentElement : this.options.wrapper;
-	}
-	/**
-	* The limit which is the maximum scroll value
-	*/
-	get limit() {
-		if (this.options.naiveDimensions) {
-			if (this.isHorizontal) return this.rootElement.scrollWidth - this.rootElement.clientWidth;
-			return this.rootElement.scrollHeight - this.rootElement.clientHeight;
-		}
-		return this.dimensions.limit[this.isHorizontal ? "x" : "y"];
-	}
-	/**
-	* Whether or not the scroll is horizontal
-	*/
-	get isHorizontal() {
-		return this.options.orientation === "horizontal";
-	}
-	/**
-	* The actual scroll value
-	*/
-	get actualScroll() {
-		const wrapper = this.options.wrapper;
-		return this.isHorizontal ? wrapper.scrollX ?? wrapper.scrollLeft : wrapper.scrollY ?? wrapper.scrollTop;
-	}
-	/**
-	* The current scroll value
-	*/
-	get scroll() {
-		return this.options.infinite ? modulo(this.animatedScroll, this.limit) : this.animatedScroll;
-	}
-	/**
-	* The progress of the scroll relative to the limit
-	*/
-	get progress() {
-		return this.limit === 0 ? 1 : this.scroll / this.limit;
-	}
-	/**
-	* Current scroll state
-	*/
-	get isScrolling() {
-		return this._isScrolling;
-	}
-	set isScrolling(value) {
-		if (this._isScrolling !== value) {
-			this._isScrolling = value;
-			this.updateClassName();
-		}
-	}
-	/**
-	* Check if lenis is stopped
-	*/
-	get isStopped() {
-		return this._isStopped;
-	}
-	set isStopped(value) {
-		if (this._isStopped !== value) {
-			this._isStopped = value;
-			this.updateClassName();
-		}
-	}
-	/**
-	* Check if lenis is locked
-	*/
-	get isLocked() {
-		return this._isLocked;
-	}
-	set isLocked(value) {
-		if (this._isLocked !== value) {
-			this._isLocked = value;
-			this.updateClassName();
-		}
-	}
-	/**
-	* Check if lenis is smooth scrolling
-	*/
-	get isSmooth() {
-		return this.isScrolling === "smooth";
-	}
-	/**
-	* Whether the user prefers reduced motion and lenis is honoring it (see `respectReducedMotion` option)
-	*/
-	get prefersReducedMotion() {
-		return this.options.respectReducedMotion && this.reducedMotionMediaQuery.matches;
-	}
-	/**
-	* The class name applied to the wrapper element
-	*/
-	get className() {
-		let className = "lenis";
-		if (this.options.autoToggle) className += " lenis-autoToggle";
-		if (this.isStopped) className += " lenis-stopped";
-		if (this.isLocked) className += " lenis-locked";
-		if (this.isScrolling) className += " lenis-scrolling";
-		if (this.isScrolling === "smooth") className += " lenis-smooth";
-		return className;
-	}
-	updateClassName() {
-		this.cleanUpClassName();
-		this.className.split(" ").forEach((className) => {
-			this.rootElement.classList.add(className);
-		});
-	}
-	cleanUpClassName() {
-		for (const className of Array.from(this.rootElement.classList)) if (className === "lenis" || className.startsWith("lenis-")) this.rootElement.classList.remove(className);
-	}
-};
-//#endregion
-export { Lenis as default };
-
-//# sourceMappingURL=lenis.mjs.map
Index: src/assets/vendor/simplewebauthn-browser.umd.min.js
===================================================================
--- src/assets/vendor/simplewebauthn-browser.umd.min.js	(revision 2165b6d3d57ceaa8b5d1f70491868482149470cf)
+++ 	(revision )
@@ -1,2 +1,0 @@
-/* [@simplewebauthn/browser@13.3.0] */
-!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).SimpleWebAuthnBrowser={})}(this,(function(e){"use strict";function t(e){const t=new Uint8Array(e);let r="";for(const e of t)r+=String.fromCharCode(e);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function r(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,n=t.padEnd(t.length+r,"="),o=atob(n),i=new ArrayBuffer(o.length),a=new Uint8Array(i);for(let e=0;e<o.length;e++)a[e]=o.charCodeAt(e);return i}function n(){return o.stubThis(void 0!==globalThis?.PublicKeyCredential&&"function"==typeof globalThis.PublicKeyCredential)}const o={stubThis:e=>e};function i(e){const{id:t}=e;return{...e,id:r(t),transports:e.transports}}function a(e){return"localhost"===e||/^((xn--[a-z0-9-]+|[a-z0-9]+(-[a-z0-9]+)*)\.)+([a-z]{2,}|xn--[a-z0-9-]+)$/i.test(e)}class s extends Error{constructor({message:e,code:t,cause:r,name:n}){super(e,{cause:r}),Object.defineProperty(this,"code",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),this.name=n??r.name,this.code=t}}const l=new class{constructor(){Object.defineProperty(this,"controller",{enumerable:!0,configurable:!0,writable:!0,value:void 0})}createNewAbortSignal(){if(this.controller){const e=new Error("Cancelling existing WebAuthn API call for new one");e.name="AbortError",this.controller.abort(e)}const e=new AbortController;return this.controller=e,e.signal}cancelCeremony(){if(this.controller){const e=new Error("Manually cancelling existing WebAuthn API call");e.name="AbortError",this.controller.abort(e),this.controller=void 0}}},c=["cross-platform","platform"];function u(e){if(e&&!(c.indexOf(e)<0))return e}function d(e,t){console.warn(`The browser extension that intercepted this WebAuthn API call incorrectly implemented ${e}. You should report this error to them.\n`,t)}function h(){if(!n())return p.stubThis(new Promise((e=>e(!1))));const e=globalThis.PublicKeyCredential;return void 0===e?.isConditionalMediationAvailable?p.stubThis(new Promise((e=>e(!1)))):p.stubThis(e.isConditionalMediationAvailable())}const p={stubThis:e=>e};e.WebAuthnAbortService=l,e.WebAuthnError=s,e._browserSupportsWebAuthnAutofillInternals=p,e._browserSupportsWebAuthnInternals=o,e.base64URLStringToBuffer=r,e.browserSupportsWebAuthn=n,e.browserSupportsWebAuthnAutofill=h,e.bufferToBase64URLString=t,e.platformAuthenticatorIsAvailable=function(){return n()?PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():new Promise((e=>e(!1)))},e.startAuthentication=async function(e){!e.optionsJSON&&e.challenge&&(console.warn("startAuthentication() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:o,useBrowserAutofill:c=!1,verifyBrowserAutofillInput:d=!0}=e;if(!n())throw new Error("WebAuthn is not supported in this browser");let p;0!==o.allowCredentials?.length&&(p=o.allowCredentials?.map(i));const f={...o,challenge:r(o.challenge),allowCredentials:p},b={};if(c){if(!await h())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1&&d)throw Error('No <input> with "webauthn" as the only or last value in its `autocomplete` attribute was detected');b.mediation="conditional",f.allowCredentials=[]}let R;b.publicKey=f,b.signal=l.createNewAbortSignal();try{R=await navigator.credentials.get(b)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new s({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if("NotAllowedError"===e.name)return new s({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("SecurityError"===e.name){const t=globalThis.location.hostname;if(!a(t))return new s({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rpId!==t)return new s({message:`The RP ID "${r.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("UnknownError"===e.name)return new s({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:b})}if(!R)throw new Error("Authentication was not completed");const{id:g,rawId:w,response:A,type:E}=R;let m;return A.userHandle&&(m=t(A.userHandle)),{id:g,rawId:t(w),response:{authenticatorData:t(A.authenticatorData),clientDataJSON:t(A.clientDataJSON),signature:t(A.signature),userHandle:m},type:E,clientExtensionResults:R.getClientExtensionResults(),authenticatorAttachment:u(R.authenticatorAttachment)}},e.startRegistration=async function(e){!e.optionsJSON&&e.challenge&&(console.warn("startRegistration() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});const{optionsJSON:o,useAutoRegister:c=!1}=e;if(!n())throw new Error("WebAuthn is not supported in this browser");const h={...o,challenge:r(o.challenge),user:{...o.user,id:r(o.user.id)},excludeCredentials:o.excludeCredentials?.map(i)},p={};let f;c&&(p.mediation="conditional"),p.publicKey=h,p.signal=l.createNewAbortSignal();try{f=await navigator.credentials.create(p)}catch(e){throw function({error:e,options:t}){const{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new s({message:"Registration ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else if("ConstraintError"===e.name){if(!0===r.authenticatorSelection?.requireResidentKey)return new s({message:"Discoverable credentials were required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT",cause:e});if("conditional"===t.mediation&&"required"===r.authenticatorSelection?.userVerification)return new s({message:"User verification was required during automatic registration but it could not be performed",code:"ERROR_AUTO_REGISTER_USER_VERIFICATION_FAILURE",cause:e});if("required"===r.authenticatorSelection?.userVerification)return new s({message:"User verification was required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT",cause:e})}else{if("InvalidStateError"===e.name)return new s({message:"The authenticator was previously registered",code:"ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED",cause:e});if("NotAllowedError"===e.name)return new s({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("NotSupportedError"===e.name)return 0===r.pubKeyCredParams.filter((e=>"public-key"===e.type)).length?new s({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new s({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if("SecurityError"===e.name){const t=globalThis.location.hostname;if(!a(t))return new s({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(r.rp.id!==t)return new s({message:`The RP ID "${r.rp.id}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("TypeError"===e.name){if(r.user.id.byteLength<1||r.user.id.byteLength>64)return new s({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",cause:e})}else if("UnknownError"===e.name)return new s({message:"The authenticator was unable to process the specified options, or could not create a new credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:p})}if(!f)throw new Error("Registration was not completed");const{id:b,rawId:R,response:g,type:w}=f;let A,E,m,y;if("function"==typeof g.getTransports&&(A=g.getTransports()),"function"==typeof g.getPublicKeyAlgorithm)try{E=g.getPublicKeyAlgorithm()}catch(e){d("getPublicKeyAlgorithm()",e)}if("function"==typeof g.getPublicKey)try{const e=g.getPublicKey();null!==e&&(m=t(e))}catch(e){d("getPublicKey()",e)}if("function"==typeof g.getAuthenticatorData)try{y=t(g.getAuthenticatorData())}catch(e){d("getAuthenticatorData()",e)}return{id:b,rawId:t(R),response:{attestationObject:t(g.attestationObject),clientDataJSON:t(g.clientDataJSON),transports:A,publicKeyAlgorithm:E,publicKey:m,authenticatorData:y},type:w,clientExtensionResults:f.getClientExtensionResults(),authenticatorAttachment:u(f.authenticatorAttachment)}}}));
