Index: CHANGELOG.de.md
===================================================================
--- CHANGELOG.de.md	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ CHANGELOG.de.md	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -5,4 +5,76 @@
 
 ## [Unreleased]
+
+## [1.7.0] · 2026-08-07
+
+### Hinzugefügt
+- **Nachrichten ist eine einzige Gesprächsansicht.** Nachrichten, Gespräche und
+  Gesendet waren drei getrennte Filter, wodurch ein Austausch auseinanderfiel:
+  was du gesendet hast, stand unter Gesendet, was zurückkam unter einem der
+  beiden anderen, und um einem Verlauf zu folgen, musstest du hin und her
+  wechseln. Es ist jetzt eine Gesprächsansicht, in der Gesendetes und
+  Empfangenes im selben Verlauf stehen, das Älteste oben, mit deinen eigenen
+  Beiträgen markiert. Vier Filter bleiben: Alle, Gespräche, Aktivität und
+  Moderation.
+- **Ein Gespräch sagt, worum es geht.** Hängt ein Verlauf an einem deiner
+  Beiträge, verlinkt die Kopfzeile darauf. Ohne diesen Zusammenhang lässt sich
+  eine Antwort in einer Liste nicht einordnen. Verläufe ohne Beitrag laufen
+  stattdessen pro Person.
+- **Beiträge in Nachrichten sehen aus wie Beiträge.** Formatierung, Bilder,
+  Audio, Video, Zitatkarten und Link-Vorschauen werden jetzt genauso dargestellt
+  wie in den Nachrichten der Zeitung — auch bei dem, was du selbst gesendet hast:
+  ein von dir angehängtes Foto kam auf dem eigenen Bildschirm als nackter Text
+  an, während alle anderen das Bild sahen.
+- **Die Guardian-App zeigt Beiträge deines Wards vollständig.** Dieselbe Lücke
+  steckte in der Guardian-Ansicht: ein Beitrag deines Wards kam ohne Medien,
+  Zitatkarte oder Emoji an — genau der Beitrag, den ein Guardian beurteilen
+  können muss. Eine Inhaltswarnung bleibt dort wie bisher eingeklappt.
+- **Aus einem Gespräch heraus antworten.** Unter einem Verlauf steht ein eigenes
+  Antwortfeld, der reichhaltige Editor mit Formatierung, Medien und Sprachwahl.
+  Winken bleibt eine eigene Schaltfläche daneben: ein Winken ist ein Zeichen,
+  keine Antwort.
+
+### Behoben
+- **Menschen auf datenschutzstrengen Servern können dir wieder folgen.** Manche
+  Server geben den öffentlichen Schlüssel eines Kontos nur an eine signierte
+  Anfrage heraus. Klonkt fragte ohne Signatur, wurde abgewiesen und konnte die
+  gerade eingegangene Folge-Anfrage deshalb nicht prüfen — sie wurde abgelehnt,
+  und der andere Server versuchte es tagelang erneut. Klonkt signiert diese
+  Abfrage jetzt, und solche Follows kommen durch. Das betraf auch alles andere,
+  was von so einem Server geholt wurde: Profile, Beiträge und Antworten.
+- **Antworten anderer kommen jetzt im Verlauf an.** Antwortete jemand auf einen
+  Beitrag in einem Gespräch, an dem du beteiligt warst, leitete dessen Server die
+  Antwort an dich weiter — und sie wurde abgewiesen, weil ein weiterleitender
+  Server mit seinem eigenen Schlüssel signiert und nicht mit dem des Autors.
+  Verläufe waren auf deiner Seite dadurch stillschweigend unvollständig. Eine
+  solche Antwort wird jetzt an der Quelle geprüft statt abgewiesen: der Beitrag
+  wird bei dem Server geholt, der ihn beherbergt, und nur das von dort wird
+  gespeichert. Eine weitergeleitete Löschung wird weiterhin abgewiesen, denn ein
+  gelöschter Beitrag lässt sich nicht prüfen.
+- **Ein Like oder Boost sieht überall gleich aus.** Derselbe Beitrag konnte in
+  der Zeitung als geliked und auf der Interact-Seite als nicht geliked
+  erscheinen, weil beide eine eigene Buchführung hatten. Es gibt jetzt nur noch
+  eine, also stimmen die Schaltflächen überein — auch für alles, worauf du vor
+  dieser Version schon reagiert hast; das wird beim Aktualisieren der Seite
+  automatisch übernommen.
+- **Ein Like aus einer App bleibt jetzt bestehen.** Ein Like aus Shaer wurde
+  zwar gespeichert, aber die App bekam das nie zurück, sodass das Herz beim
+  nächsten Laden wieder aussprang — und weil die App das Like nie sah, konnte sie
+  es nur erneut senden und nie zurücknehmen. Das Zurücknehmen aus einer App
+  funktioniert jetzt.
+
+### Sicherheit
+
+- **Aktualisierte Komponenten schließen sieben Sicherheitshinweise.** Der
+  schwerwiegendste steckte in der Bibliothek, die von außen eintreffende
+  Beiträge säubert: ein sorgfältig gebauter Beitrag konnte ein Skript daran
+  vorbeischmuggeln, und ein Skript, das auf deiner Seite läuft, kann in deinem
+  Namen handeln. Gleichzeitig ging der Mailversand drei Hauptversionen weiter
+  und schloss damit eine Reihe von Lücken — darunter eine, bei der ein
+  präparierter Name Befehle in das Gespräch mit dem Mailserver einschleusen
+  konnte, und eine, bei der eine Nachricht bei einer anderen Domain landen
+  konnte als der adressierten. An Aussehen und Verhalten von Klonkt ändert das
+  nichts. Ein Hinweis bleibt bewusst offen: er betrifft eine Art, Bezeichner zu
+  erzeugen, die Klonkt nicht verwendet.
 
 ## [1.6.0] · 2026-07-31
Index: CHANGELOG.md
===================================================================
--- CHANGELOG.md	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ CHANGELOG.md	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -5,4 +5,68 @@
 
 ## [Unreleased]
+
+## [1.7.0] · 2026-08-07
+
+### Added
+- **Messages is one conversation view.** Messages, Conversations and Sent were
+  three separate filters, so a single exchange fell apart: what you sent sat
+  under Sent, what came back under one of the other two, and you had to switch
+  filters to follow a thread. They are now one Conversations view in which sent
+  and received sit in the same thread, oldest at the top, with your own
+  contributions marked. Four filters remain: All, Conversations, Activity and
+  Moderation.
+- **A conversation says what it is about.** When a thread hangs off one of your
+  posts, its header links to that post. Without it a reply in a list is
+  impossible to place. Threads that are not about a post run per person instead.
+- **Posts in Messages look like posts.** Formatting, images, audio, video, quote
+  cards and link previews now render the same way they do in News, including on
+  the messages you sent yourself — a photo you attached used to arrive as bare
+  text on your own screen while everyone else saw the picture.
+- **The Guardian app shows a ward's posts in full.** The same gap sat in the
+  guardian view: a post from your ward arrived without its media, quote card or
+  emoji, which is exactly the post a guardian needs to be able to judge. A
+  content warning still stays collapsed there, as before.
+- **Reply from inside a conversation.** A thread has its own reply editor, the
+  rich one with formatting, media and a language picker. Waving stays a separate
+  button next to it: a wave is a nudge, not an answer.
+
+### Fixed
+- **People on privacy-strict servers can follow you again.** Some servers only
+  hand out an account's public key to a signed request. Klonkt asked without
+  signing, got turned away, and could therefore not check the follow request that
+  had just arrived — so it was refused, and the other server kept retrying for
+  days. Klonkt now signs that lookup, and those follows go through. This also
+  affected everything else fetched from such a server: profiles, posts and
+  replies.
+- **Replies from other people now arrive in threads.** When someone replied to a
+  post in a conversation you were part of, their server forwarded that reply to
+  you — and it was turned away, because the forwarding server signs with its own
+  key rather than the author's. Threads were quietly incomplete on your side.
+  Such a reply is now checked at the source instead of being refused: the post is
+  fetched from the server that hosts it, and only what comes back from there is
+  stored. A forwarded delete is still refused, because a deleted post cannot be
+  checked.
+- **A like or boost looks the same everywhere.** The same post could show as
+  liked in News and as not liked on the interact page, because both kept their
+  own record. There is one record now, so the buttons agree — including for
+  everything you reacted to before this release, which is carried over
+  automatically when the site updates.
+- **A like from an app now sticks.** Liking a post from Shaer was stored, but
+  the app never got that back, so the heart popped off again on the next reload
+  — and because the app never saw the like, it could only offer "like" again and
+  never undo it. Un-liking from an app now works.
+
+### Security
+
+- **Updated components close seven security advisories.** The heaviest one sat
+  in the library that cleans up posts arriving from elsewhere: a carefully built
+  post could slip a script past it, and a script running on your page can act as
+  if it were you. At the same time mail handling moved three major versions on,
+  closing a cluster of holes around sending — among them one where a prepared
+  name could smuggle commands into the conversation with the mail server, and
+  one where a message could end up at a different domain than the one addressed.
+  None of this changes how Klonkt looks or behaves. One advisory is knowingly
+  left open: it concerns a way of generating identifiers that Klonkt does not
+  use.
 
 ## [1.6.0] · 2026-07-31
Index: CHANGELOG.nl.md
===================================================================
--- CHANGELOG.nl.md	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ CHANGELOG.nl.md	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -5,4 +5,71 @@
 
 ## [Unreleased]
+
+## [1.7.0] · 2026-08-07
+
+### Toegevoegd
+- **Berichten is één gesprekkenweergave.** Berichten, Gesprekken en Verzonden
+  waren drie losse filters, waardoor één uitwisseling uit elkaar viel: wat jij
+  stuurde stond onder Verzonden, wat terugkwam onder een van de andere twee, en
+  om een draad te volgen moest je heen en weer klikken. Het is nu één
+  Gesprekken-weergave waarin verzonden en ontvangen in dezelfde draad staan,
+  oudste bovenaan, met jouw eigen bijdragen gemarkeerd. Er blijven vier filters
+  over: Alles, Gesprekken, Activiteit en Moderatie.
+- **Een gesprek vertelt waar het over gaat.** Hangt een draad aan een van je
+  posts, dan staat er een link naar die post in de kop. Zonder die context is
+  een antwoord in een lijst niet te plaatsen. Draden die niet over een post gaan
+  lopen per persoon.
+- **Posts in Berichten zien eruit als posts.** Opmaak, afbeeldingen, geluid,
+  video, quote-kaarten en linkvoorbeelden worden nu net zo getoond als in de
+  Krant, ook bij de berichten die je zelf stuurde — een foto die jij meestuurde
+  kwam op je eigen scherm als kale tekst binnen terwijl de ander wel een plaatje
+  zag.
+- **De Guardian-app toont posts van je ward compleet.** Hetzelfde gat zat in de
+  guardian-weergave: een post van je ward kwam binnen zonder media, quote-kaart
+  of emoji, terwijl dat juist de post is die je als guardian wilt kunnen
+  beoordelen. Een content warning blijft daar zoals eerder dichtgeklapt.
+- **Antwoorden vanuit een gesprek.** Onder een draad staat een eigen
+  antwoordvenster, de rijke editor met opmaak, media en een taalkeuze. Zwaaien
+  blijft een aparte knop ernaast: een zwaai is een seintje, geen antwoord.
+
+### Opgelost
+- **Mensen op privacy-strenge servers kunnen je weer volgen.** Sommige servers
+  geven de publieke sleutel van een account alleen aan een ondertekend verzoek.
+  Klonkt vroeg zonder handtekening, werd geweigerd, en kon daardoor het
+  volgverzoek dat net binnenkwam niet controleren — dus werd dat afgewezen, en
+  bleef de andere server het dagenlang opnieuw proberen. Klonkt ondertekent die
+  navraag nu, en die volgers komen erdoor. Dit raakte ook al het andere dat bij
+  zo'n server werd opgehaald: profielen, posts en reacties.
+- **Reacties van anderen komen nu in de draad aan.** Reageerde iemand op een
+  post in een gesprek waar jij bij zat, dan stuurde hun server die reactie naar
+  je door — en werd hij geweigerd, omdat een doorsturende server met zijn eigen
+  sleutel ondertekent en niet met die van de schrijver. Threads waren daardoor
+  aan jouw kant stilletjes onvolledig. Zo'n reactie wordt nu bij de bron
+  gecontroleerd in plaats van afgewezen: de post wordt opgehaald bij de server
+  die hem herbergt, en alleen wat daarvandaan komt wordt bewaard. Een
+  doorgestuurde verwijdering wordt nog steeds geweigerd, want een verwijderde
+  post valt niet te controleren.
+- **Een like of boost ziet er overal hetzelfde uit.** Dezelfde post kon in de
+  Krant als geliket verschijnen en op de interact-pagina als niet-geliket, omdat
+  allebei een eigen administratie bijhielden. Er is er nu één, dus de knoppen
+  zijn het eens — ook voor alles wat je vóór deze versie al had gereageerd; dat
+  wordt bij het bijwerken van de site automatisch meegenomen.
+- **Een like vanuit een app blijft nu staan.** Een like die je in Shaer gaf werd
+  wel opgeslagen, maar de app kreeg dat nooit terug, dus het hartje sprong bij de
+  eerste herlaadbeurt weer uit — en omdat de app de like nooit zag, kon hij hem
+  alleen opnieuw geven en nooit intrekken. Un-liken vanuit een app werkt nu.
+
+### Beveiliging
+
+- **Bijgewerkte onderdelen sluiten zeven beveiligingsadviezen.** Het zwaarste
+  zat in de bibliotheek die berichten van elders opschoont: een zorgvuldig
+  opgebouwd bericht kon daar een script langs krijgen, en een script dat op jouw
+  pagina draait kan doen alsof het jou is. Tegelijk ging de mailafhandeling drie
+  hoofdversies vooruit, wat een reeks gaten rond verzenden dichtte — waaronder
+  een waarbij een geprepareerde naam commando's het gesprek met de mailserver in
+  kon smokkelen, en een waarbij een bericht bij een ander domein kon belanden
+  dan het geadresseerde. Aan hoe Klonkt eruitziet of werkt verandert dit niets.
+  Eén advies blijft bewust staan: het gaat over een manier om identifiers te
+  maken die Klonkt niet gebruikt.
 
 ## [1.6.0] · 2026-07-31
Index: deploy/HOSTING.md
===================================================================
--- deploy/HOSTING.md	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ deploy/HOSTING.md	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,266 @@
+# Running Klonkt for other people
+
+A runbook for someone who has a VPS and is about to host Klonkt instances for
+people who are not themselves.
+
+This is **not** an install guide. For that:
+
+| | |
+|---|---|
+| [DEPLOY.md](DEPLOY.md) | one Klonkt, from bare VPS to running |
+| [MULTI-INSTANCE.md](MULTI-INSTANCE.md) | the split layout, `klonkt@<slug>` units |
+
+Those tell you how. This one tells you what changes the moment the data on your
+disk stops being yours.
+
+---
+
+## 0. The question to answer first
+
+**Are you hosting for yourself, or for other people?**
+
+If it is only you, stop reading and use the two guides above. Everything below is
+about the second case, and the difference is not technical. One Klonkt is one
+person, so the moment there is a second instance there is a second person, and
+you are now holding their conversations, their contacts, and their identity.
+
+Read §5 before you accept your first user, not after.
+
+---
+
+## 1. Day one
+
+Follow [MULTI-INSTANCE.md](MULTI-INSTANCE.md) for the layout, then:
+
+```bash
+sudo bash /opt/klonkt/scripts/klonkt-add-instance.sh <slug> <domain>
+```
+
+Leave the port out and a free one is chosen. Then check it came up:
+
+```bash
+systemctl status klonkt@<slug>
+```
+
+Before you tell anyone it is ready, do these three:
+
+1. **Test the backup, do not just install it.** Run `deploy/backup.sh` once by
+   hand and then actually unpack the tarball somewhere and look inside. A backup
+   you have never restored is a hope, not a backup.
+2. **Check the disk.** Media grows quietly; databases do not. `df -h` now, and
+   know what number worries you.
+3. **Update once on purpose**, while nobody is depending on it, so the first time
+   you run `klonkt-update` is not also the first time you find out what it does.
+
+---
+
+## 2. The weekly rhythm
+
+Nothing here takes long. Skipping it is how small problems become 3am problems.
+
+```bash
+systemctl list-units 'klonkt@*'                                  # still running?
+df -h                                                            # disk
+journalctl -u 'klonkt@*' --since '7 days ago' -p warning --no-pager
+```
+
+If that last one answers `Failed to add filter for units: No data available`,
+nothing matched — which is usually the good news (no warnings in the window), not
+a broken command. Widen the window before you go looking for a fault.
+
+`-u` matches against the units that actually appear in the journal, so an
+instance that logged nothing at all is simply absent. Run it as root: an
+unprivileged account sees only its own messages and will get the same empty
+answer for the wrong reason.
+
+The log line worth reading properly is anything about federation: a rejected
+signature, a delivery that keeps retrying. Those are usually the other side's
+problem, but they are also how you find out that one of your users has been
+invisible to half the fediverse for a week.
+
+---
+
+## 3. Updating, and getting back
+
+```bash
+klonkt-update
+```
+
+One command updates the shared code and restarts **every** instance on the
+machine. That is the point of the shared layout, and it is also the risk: a bad
+release takes all of your users down at once, not one.
+
+So before you update:
+
+- **know what you are on.** `git -C /opt/klonkt rev-parse --abbrev-ref HEAD`.
+  `stable` is what you want unless you have decided otherwise on purpose.
+- **do it when you are awake.** Not before you leave the house.
+
+`klonkt-update` prints the previous commit and the command to go back. It looks
+like this:
+
+```bash
+runuser -u klonkt -- git -C /opt/klonkt checkout -qf -B stable <previous-sha>
+systemctl restart 'klonkt@*'
+```
+
+The glob only matches units systemd already has loaded. An instance that was
+stopped will be skipped, so check with `systemctl list-units 'klonkt@*'
+--all` afterwards rather than assuming.
+
+That only works if the checkout has history. Older installs were cloned shallow
+and could not roll back at all; the updater now deepens once, on its first run
+after this change. If you are not sure:
+
+```bash
+git -C /opt/klonkt rev-parse --is-shallow-repository   # want: false
+```
+
+`true` means you cannot roll back yet. Run
+`sudo bash /opt/klonkt/scripts/klonkt-refresh-updater.sh` and then
+`klonkt-update` once.
+
+**Roll back is not atomic.** All instances share one checkout, so during the
+switch the code is briefly half-old. For a handful of instances this is seconds
+and nobody notices. It is still the reason to keep a maintenance page in mind if
+you ever grow.
+
+---
+
+## 4. When it breaks
+
+**An instance will not start.**
+
+```bash
+journalctl -u klonkt@<slug> -n 50 --no-pager
+```
+
+Nine times out of ten it is the `.env`: a missing `SESSION_SECRET`, or a path
+that points at a directory that no longer exists. The app refuses to boot rather
+than come up half-configured, which is deliberate.
+
+**One instance is broken, the rest are fine.** Do not run `klonkt-update` hoping
+it helps — it restarts everyone to fix one. Restart the single unit.
+
+**Everything is broken after an update.** Roll back (§3) first, ask why second.
+
+**Disk full.** Media, then logs. `journalctl --vacuum-size=200M` buys you room
+immediately; the media is a conversation with the user, not a delete decision you
+make alone.
+
+---
+
+## 5. What you are actually holding
+
+This is the section people skip. Do not.
+
+Each instance's data directory contains that person's database: their posts,
+their private messages, who they follow, and — if they are a ward or a guardian —
+their guardianship relationships. It also contains **the private key of their
+fediverse actor**.
+
+That last one is not a privacy nuance. Whoever has that key can post as that
+person, follow as that person, and send messages as that person, and the rest of
+the fediverse has no way to tell. You have it because you have the machine. There
+is no configuration that takes it away.
+
+So:
+
+- **Say so.** Tell the people you host, in plain words, that you can technically
+  read and impersonate. It is better coming from you than discovered later.
+- **Do not build tooling that makes it easy.** Never export or display actor keys
+  from an admin panel. It prevents nothing, but a tool that offers it invites it.
+- **Guardianship data deserves more care than the rest.** A ward is often a
+  minor, and a 🛟 help request is a distress signal. That a guardianship exists
+  may be visible to you for support. What is in it is not yours to read.
+
+### If someone changes hoster
+
+An ActivityPub `Move` does **not** apply here. Move takes an actor from one id to
+another and tells the followers to re-follow. If the domain stays the same, the
+actor id stays the same, and there is nothing to move: the fediverse only ever
+sees the URI and cannot tell which machine answers it. Changing hoster with the
+same domain is a server migration. Copy the data across and the world notices
+nothing.
+
+Which leaves the key, and Move would not have helped with that either — a Move
+carries no keys; the new actor simply has its own.
+
+So the choice is: take the key along, or make a new one.
+
+Take it along and everything keeps working — **and the old hoster keeps a working
+copy, permanently.** ActivityPub has no revocation. Nothing marks a key as no
+longer valid; it is only superseded once other servers refetch the actor.
+
+Klonkt cannot rotate keys today. `getOrCreateKeys()` creates a pair when there is
+none and never replaces one. So for now, treat a change of hoster as what it is:
+the previous hoster can go on signing as that person, and the only real mitigation
+is choosing hosters you would trust after the fact.
+
+Say this out loud to anyone leaving you, and to anyone arriving.
+
+The fediverse is working on the root of this. FEP-521a (final) already lets an
+actor publish several keys at once, which is what a graceful rotation would need —
+though it deliberately stops at the representation and says nothing about when an
+old key stops counting. FEP-ef61, *Portable Objects* (draft), goes further: it
+gives objects server-independent ids and allows the signing key to live with the
+**user** instead of the server. On that model a change of hoster leaks nothing,
+because the hoster never held the key. That is where this should end up; it is not
+where it is today.
+
+### The legal shape
+
+Under the GDPR you are a **processor**: you handle personal data on behalf of
+someone else, who is the controller. That relationship needs a written agreement
+(Art. 28). This is true even if you host for free, and even for one friend.
+
+If you also use the data for your own purposes — statistics across instances,
+moderating what people post, backups you keep for your own reasons — you are no
+longer only a processor, and the bar is higher.
+
+The single-user design helps you here more than you would expect. A request to
+see or delete everything about one person is *one instance*, not a query across
+shared tables. You can answer it honestly, and prove it.
+
+---
+
+## 6. Letting someone leave
+
+A hoster who cannot be left is not a hoster. Klonkt has a portable content
+archive; see [../docs/EXPORT-FORMAT.md](../docs/EXPORT-FORMAT.md).
+
+```bash
+cd /opt/klonkt
+node scripts/export-archive.mjs <slug> --out /tmp/<slug>-archive.zip
+```
+
+It contains their posts, their media, and the replies underneath as a read-only
+record. No credentials, no keys. Hand it over and it can be imported into another
+Klonkt.
+
+Two things to know before you hand it over:
+
+- If the site has **paid posts**, the archive contains their full text. Treat the
+  file like the content itself.
+- The **ActivityPub ids survive** only if the new home has the same domain.
+  Moving to a different domain means new ids, and the boosts and replies that
+  point at the old ones do not follow. That is a property of the fediverse, not
+  of the export.
+
+Then remove the instance ([MULTI-INSTANCE.md](MULTI-INSTANCE.md) §Removing an
+instance) — and only after they confirm the archive opens.
+
+---
+
+## 7. Things not to do
+
+- **Do not host for people you would not tell about §5.** If you would rather
+  they did not know what you can see, you should not be holding it.
+- **Do not update to fix one instance.** It restarts all of them.
+- **Do not put security updates behind anything.** If you ever charge for
+  hosting, charge for the convenience — scheduled, all at once, with a way back
+  — never for the update itself. An unpatched fediverse server is a problem for
+  everyone it talks to, not only for you.
+- **Do not keep backups you have never restored.** See §1.
+- **Do not delete someone's instance the same day they ask.** Export first,
+  confirm they can open it, then remove.
Index: deploy/MULTI-INSTANCE.md
===================================================================
--- deploy/MULTI-INSTANCE.md	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ deploy/MULTI-INSTANCE.md	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -195,4 +195,10 @@
 Then remove its block from the web server config and reload it.
 
+## Hosting for other people
+
+Everything here is about the machine. The moment an instance belongs to someone
+who is not you, there is a second set of questions — what you are holding, what
+you owe them, and how they leave. See [HOSTING.md](HOSTING.md).
+
 ## Everyday commands
 
Index: docs/EXPORT-FORMAT.md
===================================================================
--- docs/EXPORT-FORMAT.md	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ docs/EXPORT-FORMAT.md	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,302 @@
+# Klonkt Content Archive, format version 1
+
+A portable archive of **your own content**: posts, their media, and — as a
+read-only historical record — the replies other people wrote underneath them.
+It can be exported from one Klonkt and imported into another, and it stays
+readable when Klonkt is gone.
+
+This is the format specification. It is written before the exporter and the
+importer exist, on purpose: without a written format each of them writes down
+its own assumptions, and then there are two sources of truth instead of one.
+
+## What this is not
+
+| | |
+|---|---|
+| **Storage backup** (`shaer-190t`) | A byte-level zip of the storage directory, including password hashes, sessions and the per-actor private RSA keys. A backup for Klonkt-to-Klonkt restore. Never hand it to anyone. |
+| **GDPR data export** (`shaer-0j2`) | Personal data for inspection. Different scope, different audience. |
+| **This format** | Your content, portable, importable, reproducible, and free of every credential. |
+
+They may share serialisation code. They do not share a purpose, and they must
+not quietly become the same file.
+
+## Container layout
+
+```
+manifest.json              format version, origin, counts, checksums
+posts/<post-id>.json       ONE post — canonical, the only thing import reads
+replies/<post-id>.json     replies received under that post — read-only archive
+media/<sha256>.<ext>       the bytes, content-addressed
+readable/<slug>.md         rendered for humans — derived, NEVER read on import
+```
+
+Delivered as a zip. `<post-id>` is Klonkt's own post id, which is also the tail
+of the ActivityPub object id (`<origin>/ap/notes/<post-id>`). Using it as the
+filename means the identity is visible in the tree and no character escaping is
+needed. The slug lives inside the file, where it can change without renaming
+anything.
+
+## manifest.json
+
+```json
+{
+  "formatVersion": 1,
+  "generator": "klonkt/1.6.0",
+  "exportedAt": "2026-08-06T13:00:00Z",
+  "origin": "https://boiert.eu",
+  "actor": "https://boiert.eu/ap/users/boiert",
+  "site": { "slug": "boiert", "title": "Boiert" },
+  "counts": { "posts": 412, "replies": 1180, "media": 233, "mediaMissing": 7 },
+  "files": { "posts/abc123.json": "<sha256>", "media/<sha256>.jpg": "<sha256>" }
+}
+```
+
+`origin` is the field the importer cannot work without. It decides whether the
+ActivityPub ids may be kept (see [Import rules](#import-rules)). Everything else
+in the manifest is verification and reporting.
+
+`exportedAt` lives **only** here. It must not appear in any post file, or two
+exports of unchanged content would differ.
+
+## posts/&lt;post-id&gt;.json
+
+An ActivityStreams 2.0 object carrying the post **as authored**, with Klonkt's
+own fields in the `shaer:` namespace.
+
+### It is not what we federate, and that is deliberate
+
+`buildNote()` produces a *projection* for the fediverse, and that projection is
+lossy by design:
+
+- the title is baked into the content as a bold first line, because Mastodon
+  ignores a Note's `name`;
+- a paid post federates a **teaser only**, never the body;
+- `<img>` is stripped from the content and moved into `attachment`, because
+  Mastodon drops inline images;
+- when a post carries an external embed the image attachments are suppressed, so
+  the link card wins.
+
+Exporting that projection would mean archiving a paid post as its teaser. So the
+archive carries the authoring truth instead, and the differences are:
+
+| | federated (`buildNote`) | archive |
+|---|---|---|
+| type | always `Note` | `Article` when the post has a title, else `Note` |
+| title | inside `content` | `name` |
+| paid post body | teaser | full content |
+| inline images | stripped from `content` | left in `content`, and also listed in `attachment` |
+
+### Fields
+
+| AS2 / `shaer:` | source | notes |
+|---|---|---|
+| `id` | `<origin>/ap/notes/<posts.id>` | the federation identity; see import rules |
+| `type` | `posts.title` present? | `Article` / `Note` |
+| `attributedTo` | site actor | |
+| `name` | `posts.title` | plain text, not HTML |
+| `content` | `posts.content` | authored HTML, shorthand intact |
+| `contentMap` | `posts.language` | omitted when the post has no language |
+| `summary` | `posts.content_warning` | AS2 summary is the content warning |
+| `sensitive` | `posts.nsfw` | |
+| `published` | `posts.published_at` ?? `created_at` | ISO 8601, UTC. Klonkt stores timestamps in two spellings (`YYYY-MM-DD HH:MM:SS` and full ISO); the archive normalises to ISO. The **instant** survives a round trip, the spelling does not. |
+| `updated` | `posts.updated_at` | omitted when equal to `published` |
+| `url` | `<origin>/<slug>` | the human permalink |
+| `attachment` | cover, inline `<img>`, `c2s_attachments` **and their `poster`**, hosted audio tracks | see [Media](#media); each carries a `shaer:role` |
+| `tag` | `posts.tags`, mentions, custom emoji | `Hashtag`, `Mention`, `toot:Emoji` |
+| `oneOf` / `anyOf` / `endTime` | `posts.poll_json` | a poll exports as a `Question` |
+| `quoteUrl`, `shaer:quoteActor` | `posts.quote_uri`, `quote_actor` | FEP-044f |
+| `shaer:slug` | `posts.slug` | |
+| `shaer:status` | `posts.status` | `draft` posts are yours too and are exported |
+| `shaer:excerpt` | `posts.excerpt` | |
+| `shaer:type` | `posts.type` | |
+| `shaer:pinned`, `shaer:noindex`, `shaer:fanOnly` | idem | |
+| `shaer:paid`, `shaer:paidMinCents` | idem | **see the warning below** |
+| `shaer:apVisibility` | `posts.ap_visibility` | |
+| `shaer:publishAt` | `posts.publish_at` | a scheduled post keeps its schedule |
+| `shaer:coverAlt` | `posts.cover_alt` | |
+| `shaer:viewCount` | `posts.view_count` | optional; a restore without it loses history |
+| `shaer:audio` | `audio_tracks` rows referenced from the content | title, artist, duration, credit, license, external links, `shaer:ref` (the `[[track:id]]` it belongs to) and `shaer:media` (which attachment holds its file) |
+
+**Deliberately not exported:** `yjs_binary` (collaborative editor state — large,
+regenerable, and meaningless elsewhere), `content_rendered` (derived from
+`content`; exporting it would create a second truth that can drift),
+`origin_server`, and every id that belongs to a foreign server.
+
+> **Warning — a paid post exports in the clear.** The archive contains the full
+> body of paywalled posts, because an archive that silently drops your own
+> content is worse than useless. An export of a site with paid posts must be
+> handled like the content itself.
+
+### Klonkt shorthand inside `content`
+
+`content` may contain Klonkt shorthand that other software will not understand:
+
+```
+[[track:<id>]]      a hosted audio track       → see shaer:audio
+[[album:<id>]]      an album of tracks
+[[playlist:<id>]]   a playlist
+[[embed:<url>]]     an external player (Spotify, YouTube, SoundCloud, …)
+```
+
+They are left in place, because resolving them at export would destroy the
+authored source. A foreign consumer should either strip them or resolve them
+using `shaer:audio`. `readable/<slug>.md` has them rendered.
+
+## Media
+
+Media are content-addressed by the SHA-256 of the bytes, so two exports of the
+same content produce the same filenames and duplicates collapse on their own.
+
+An attachment has three possible states, and the format must be able to say
+which one applies. Two are not enough: at Boiert we will know that a picture
+existed and where it lived, without having the bytes. An empty attachment list
+would be a lie, and a reference to a missing file would be a broken archive.
+
+```json
+{
+  "type": "Image",
+  "mediaType": "image/jpeg",
+  "name": "alt text",
+  "url": "media/9f86d0….jpg",
+  "shaer:availability": "included",
+  "shaer:originalUrl": "https://boiert.eu/media/2026/cover.jpg",
+  "shaer:sha256": "9f86d0…"
+}
+```
+
+| `shaer:availability` | meaning | `url` |
+|---|---|---|
+| `included` | the bytes are in `media/` | container-relative path |
+| `missing` | it existed, we know where, we do not have it | the original absolute URL |
+
+Every attachment also carries **`shaer:role`**, saying what it was for:
+
+| role | restored to |
+|---|---|
+| `cover` / `coverVideo` | `posts.cover_image_url` / `cover_video_url` |
+| `inline` | already referenced from `content` |
+| `c2s` | an entry in `posts.c2s_attachments` |
+| `poster` | the `poster` of the `c2s` entry named in `shaer:posterFor` |
+| `track` | the file behind a `[[track:id]]`, linked from `shaer:audio` |
+
+The role is not decoration. Without it the archive holds the bytes but not the
+fact that they *were the cover*, and the post comes back without one — which is
+invisible until you compare every column, not a handful.
+
+`url` for an included attachment is a **container-relative path**, not a URL. An
+importer must rewrite it. This is the one place where the archive deviates from
+strict AS2, and it is the price of carrying bytes at all.
+
+An importer must **count and report** every `missing` attachment. It must never
+silently drop one: a post whose picture has vanished should say so.
+
+## replies/&lt;post-id&gt;.json
+
+An AS2 `OrderedCollection` of the replies received under that post, as received.
+
+```json
+{
+  "type": "OrderedCollection",
+  "shaer:archive": true,
+  "shaer:inReplyTo": "https://boiert.eu/ap/notes/abc123",
+  "orderedItems": [ { "id": "https://mstdn.social/users/x/statuses/1", "…": "…" } ]
+}
+```
+
+This is **other people's content**. Three rules follow, and none is optional:
+
+1. It is never re-delivered, produces no notifications and no push. It is
+   history, not traffic.
+2. Their ids belong to their servers. We carry them as they are and never claim
+   them.
+3. Because of this section an export is not purely "your data". That is exactly
+   why this format and the GDPR export (`shaer-0j2`) have different scopes and
+   must not silently become the same file.
+
+`shaer:archive: true` is what an importer keys on. An importer that does not
+understand this section must skip it, never read it as own content.
+
+## readable/&lt;slug&gt;.md
+
+Markdown with YAML front matter, rendered from the canonical file: title, date,
+content with the shorthand resolved, images as links into `media/`.
+
+It exists so the archive survives Klonkt itself. It is **derived** and travels
+in one direction only — the importer never reads it. This is not tidiness: an
+archive whose Markdown and JSON are both read is an archive with two sources of
+truth, and they will drift.
+
+## Versioning
+
+`formatVersion` is an integer in `manifest.json`.
+
+- Adding a property does **not** bump it. Readers must ignore properties they do
+  not know.
+- Changing or removing the meaning of a property bumps it.
+- An importer that meets a `formatVersion` **higher than it knows must refuse the
+  whole archive** and say so. It must never read it partially: a half-understood
+  restore is worse than no restore, because it looks like it worked.
+
+## Import rules
+
+**Identity.** Compare `manifest.origin` with the importing site's own origin.
+
+- Equal → **keep the ActivityPub ids**. This is what makes a restore a restore:
+  the boosts, likes and replies that already point at those ids find their post
+  again.
+- Different → **mint new ids**, and keep the original `url` as a historical
+  reference. Keeping foreign ids would mean publishing objects under an id you
+  do not control; remote servers resolve such an id at its original host, and it
+  is a forgery surface besides.
+
+The importer determines this itself. It is not a question for the user, because
+a wrong answer cannot be taken back.
+
+**Consequences of keeping ids.** The post id is part of the identity, so the
+importer must be able to *set* the primary key rather than let it be generated.
+That is a different write path from creating a post, and it must not be reachable
+outside the import.
+
+**No `Update` broadcast.** If the restored content differs from what other
+servers cached long ago, their copy stays stale. Sending `Update` activities
+would fix that, but it is a broadcast to the whole fediverse and must never be a
+side effect of an import. Separate, deliberate action.
+
+**Dry run.** The importer offers a mode that reports what would happen —
+including the missing-media count — before anything is written.
+
+**Idempotent.** Importing the same archive twice produces no duplicates. On a
+collision with an existing id the importer follows one documented rule (skip,
+overwrite, or refuse and report); it never guesses per post.
+
+## Reproducibility
+
+Two exports of unchanged content must be **byte-identical except for
+`manifest.exportedAt`**, which is by definition the moment of export. Everything
+else — every post file, every media file, and the `files` checksum map — must
+match. That is what makes an archive verifiable and a diff meaningful: if the
+checksum map is equal, nothing changed.
+
+- JSON: keys sorted, two-space indent, LF, trailing newline.
+- Posts ordered by `published`, then by `id` for ties.
+- No timestamps inside post files; `exportedAt` lives only in the manifest.
+- Zip entries in sorted path order, with a fixed modification time.
+- Media names derive from the bytes, so they never depend on export order.
+
+## Worked example: the hardest post
+
+The format is tested against the most demanding post Klonkt can produce, not the
+easiest: a **poll** with **attachments**, a **quote card**, **custom emoji**, a
+**content warning**, and **paid** access.
+
+- poll → `type: "Question"` with `oneOf`/`anyOf` and `endTime`; the votes are
+  federation state, not content, and are not exported;
+- attachments → `attachment[]`, each with its own availability;
+- quote → `quoteUrl` + `shaer:quoteActor` (FEP-044f);
+- custom emoji → `tag[]` entries of `toot:Emoji`, whose icons are media like any
+  other and are content-addressed the same way;
+- content warning → `summary` + `sensitive: true`;
+- paid → full `content` plus `shaer:paid`, with the warning above.
+
+A post that survives this round trip unchanged is the acceptance test for
+slices 2 and 3.
Index: docs/shaer-c2s-api.md
===================================================================
--- docs/shaer-c2s-api.md	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ docs/shaer-c2s-api.md	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -80,4 +80,63 @@
 | `GET /ap/users/:slug/outbox` | OrderedCollection of the account's own `Create(Note)` |
 | `GET /ap/users/:slug/inbox` | OrderedCollection of recent inbound posts (accounts you follow) as `Create(Note)`. Owner only; `403` for anyone else. The unified home feed is outbox + inbox merged. |
+
+### Waiting for news on the inbox read
+
+The inbox read can hold the answer until there is something new, so a client does
+not have to poll. It is the **same call and the same response** — no separate
+endpoint and no separate "there is news" shape, because a second shape is a
+second description of a post that can drift from the first.
+
+```
+GET /ap/users/:slug/inbox?since=<cursor>&wait=25
+```
+
+- `since` is the `shaer:cursor` from your previous answer. Treat it as opaque.
+- `wait` is seconds, capped at 50 — well under what a proxy will hold.
+- Send neither and the route behaves exactly as it always did.
+
+Two possible answers:
+
+| | |
+|---|---|
+| **`200`** | something changed. The full, current collection with a fresh `shaer:cursor`. |
+| **`304`** | nothing changed within `wait`. **No body.** Keep the cursor you have and ask again. |
+
+`304` is not an error — it is the normal answer to a quiet minute, and it is why
+this costs nothing while nothing happens. Sending the whole timeline back every
+`wait` seconds just to say "still nothing" would be a poor trade for saving one
+round trip.
+
+A server that has not run the feed-state migration yet cannot tell change from
+stillness, and answers `200` with the collection every time rather than `304`
+forever. Slower, never wrong.
+
+The cursor moves for **all four** sources this read merges: the timeline,
+messages, replies on your own posts, and your own sent notes.
+
+Limits worth knowing as a client author:
+
+- four waiting connections per account. A fifth gets the current state
+  immediately rather than an error, so a broken reconnect loop degrades to
+  ordinary polling instead of locking the account out.
+- hanging up ends the wait; there is no cost to abandoning a request.
+- the bearer is checked *before* any waiting, so an unauthenticated caller can
+  never hold a connection open.
+
+This works while your app is in the foreground, and only there. iOS freezes
+network activity the moment an app is backgrounded, and a held-open connection
+does not survive it.
+
+**There is no background channel to fall back on for a native app.** Klonkt's
+web push uses VAPID, which a self-hosted instance can generate and send entirely
+on its own — but only to a browser or an installed PWA. A native app receives
+push through APNs (or FCM), signed with the *application's* key, which a
+self-hosted Klonkt does not have and should not have: whoever holds it can push
+to every user of that app. Closing that gap needs a relay run by the app's
+publisher, and that is a decision about money and control, not a protocol
+detail.
+
+So for a self-hosted setup this is not a stopgap until push arrives. For now it
+is the freshness channel.
 | `GET /ap/users/:slug/followers` | see below |
 | `GET /ap/users/:slug/following` | see below |
Index: package-lock.json
===================================================================
--- package-lock.json	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ package-lock.json	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -1,5 +1,5 @@
 {
   "name": "klonkt",
-  "version": "1.6.0-dev",
+  "version": "1.7.0",
   "lockfileVersion": 3,
   "requires": true,
@@ -7,5 +7,5 @@
     "": {
       "name": "klonkt",
-      "version": "1.6.0-dev",
+      "version": "1.7.0",
       "license": "AGPL-3.0-or-later",
       "dependencies": {
@@ -28,5 +28,5 @@
         "multer": "^1.4.5-lts.1",
         "node-webpmux": "^3.2.1",
-        "nodemailer": "^6.9.15",
+        "nodemailer": "^9.0.5",
         "qrcode": "^1.5.4",
         "sanitize-html": "^2.17.3",
@@ -680,7 +680,7 @@
     },
     "node_modules/body-parser": {
-      "version": "1.20.5",
-      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
-      "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+      "version": "1.20.6",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+      "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
       "license": "MIT",
       "dependencies": {
@@ -704,7 +704,7 @@
     },
     "node_modules/brace-expansion": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
-      "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+      "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
       "license": "MIT",
       "dependencies": {
@@ -934,4 +934,10 @@
       "license": "MIT"
     },
+    "node_modules/dayjs": {
+      "version": "1.11.21",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
+      "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
+      "license": "MIT"
+    },
     "node_modules/debug": {
       "version": "2.6.9",
@@ -1206,7 +1212,7 @@
     },
     "node_modules/es-object-atoms": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
-      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
       "license": "MIT",
       "dependencies": {
@@ -1254,12 +1260,12 @@
     },
     "node_modules/express": {
-      "version": "4.22.1",
-      "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
-      "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+      "version": "4.22.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
       "license": "MIT",
       "dependencies": {
         "accepts": "~1.3.8",
         "array-flatten": "1.1.1",
-        "body-parser": "~1.20.3",
+        "body-parser": "~1.20.5",
         "content-disposition": "~0.5.4",
         "content-type": "~1.0.4",
@@ -1280,5 +1286,5 @@
         "path-to-regexp": "~0.1.12",
         "proxy-addr": "~2.0.7",
-        "qs": "~6.14.0",
+        "qs": "~6.15.1",
         "range-parser": "~1.2.1",
         "safe-buffer": "5.2.1",
@@ -1335,19 +1341,4 @@
         "type": "opencollective",
         "url": "https://opencollective.com/express"
-      }
-    },
-    "node_modules/express/node_modules/qs": {
-      "version": "6.14.2",
-      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
-      "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "side-channel": "^1.1.0"
-      },
-      "engines": {
-        "node": ">=0.6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
       }
     },
@@ -1543,7 +1534,7 @@
     },
     "node_modules/hasown": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
-      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
       "license": "MIT",
       "dependencies": {
@@ -1783,4 +1774,13 @@
       }
     },
+    "node_modules/launder": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
+      "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
+      "license": "MIT",
+      "dependencies": {
+        "dayjs": "^1.11.7"
+      }
+    },
     "node_modules/locate-path": {
       "version": "5.0.0",
@@ -1959,7 +1959,7 @@
     },
     "node_modules/nanoid": {
-      "version": "3.3.11",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
-      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "version": "3.3.17",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
+      "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
       "funding": [
         {
@@ -2010,7 +2010,7 @@
     },
     "node_modules/nodemailer": {
-      "version": "6.10.1",
-      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
-      "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
+      "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
       "license": "MIT-0",
       "engines": {
@@ -2156,7 +2156,7 @@
     },
     "node_modules/postcss": {
-      "version": "8.5.12",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
-      "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
+      "version": "8.5.26",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+      "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
       "funding": [
         {
@@ -2175,5 +2175,5 @@
       "license": "MIT",
       "dependencies": {
-        "nanoid": "^3.3.11",
+        "nanoid": "^3.3.17",
         "picocolors": "^1.1.1",
         "source-map-js": "^1.2.1"
@@ -2284,10 +2284,11 @@
     },
     "node_modules/qs": {
-      "version": "6.15.1",
-      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
-      "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+      "version": "6.15.3",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
       "license": "BSD-3-Clause",
       "dependencies": {
-        "side-channel": "^1.1.0"
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
       },
       "engines": {
@@ -2408,7 +2409,7 @@
     },
     "node_modules/sanitize-html": {
-      "version": "2.17.3",
-      "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz",
-      "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==",
+      "version": "2.17.5",
+      "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
+      "integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
       "license": "MIT",
       "dependencies": {
@@ -2417,4 +2418,5 @@
         "htmlparser2": "^10.1.0",
         "is-plain-object": "^5.0.0",
+        "launder": "^1.7.1",
         "parse-srcset": "^1.0.2",
         "postcss": "^8.3.11"
@@ -2491,12 +2493,12 @@
     },
     "node_modules/side-channel": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
-      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
       "license": "MIT",
       "dependencies": {
         "es-errors": "^1.3.0",
-        "object-inspect": "^1.13.3",
-        "side-channel-list": "^1.0.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
         "side-channel-map": "^1.0.1",
         "side-channel-weakmap": "^1.0.2"
Index: package.json
===================================================================
--- package.json	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ package.json	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -1,5 +1,5 @@
 {
   "name": "klonkt",
-  "version": "1.6.0",
+  "version": "1.7.0",
   "description": "Klonkt — self-hosted multi-site music & blog platform (solo, hub or circles). Node + SQLite + htmx.",
   "license": "AGPL-3.0-or-later",
@@ -33,5 +33,5 @@
     "multer": "^1.4.5-lts.1",
     "node-webpmux": "^3.2.1",
-    "nodemailer": "^6.9.15",
+    "nodemailer": "^9.0.5",
     "qrcode": "^1.5.4",
     "sanitize-html": "^2.17.3",
Index: scripts/backfill-reactions.mjs
===================================================================
--- scripts/backfill-reactions.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ scripts/backfill-reactions.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,107 @@
+#!/usr/bin/env node
+//
+// Reactie-migratie handmatig draaien (shaer-9e9).
+//
+// Normaal hoef je dit NIET: migrateReactions() draait bij boot, één keer per
+// REACTIONS_MIGRATION_VERSION-bump, net als de self-heal. Dit script is er om
+// vooraf te kijken wat er zou gebeuren, of om het gericht op één instance te
+// forceren.
+//
+//     node scripts/backfill-reactions.mjs --dry-run
+//     node scripts/backfill-reactions.mjs            # respecteert de versievlag
+//     node scripts/backfill-reactions.mjs --force    # ook als de vlag al staat
+//
+// Bewust een schil om dezelfde functie die bij boot draait: twee implementaties
+// van een migratie lopen uiteen, en dan repareert de ene wat de andere niet ziet.
+//
+// Wat het doet, en waarom allebei nodig is:
+//
+//   HERSLEUTELEN  De oude interact-route bewaarde de URI waarmee je binnenkwam,
+//                 en de bookmarklet geeft de permalink door. Sinds de reacties
+//                 op de canonieke object-URI gezocht worden, zouden die rijen
+//                 wees zijn. De created_at reist mee.
+//   AANVULLEN     Alles wat op oude code via de Krant is gegeven staat alleen in
+//                 ap_timeline.liked/boosted. Zonder deze stap toont het als
+//                 niet-gereageerd -- en klikt iemand opnieuw, met een tweede
+//                 Like de fediverse in als gevolg.
+//   UIT REACTIES  De derde bron (shaer-ipb): ap_interactions.acted_like/_boost,
+//                 wat jij deed met een reactie ONDER je eigen post. Zelfde
+//                 gevolg als hierboven, alleen op een ander oppervlak.
+//
+// Wat het NIET kan: bij AANVULLEN de oorspronkelijke reactiedatum herstellen.
+// Wanneer je reageerde is nergens vastgelegd, dus die rijen krijgen de datum van
+// nu. Bij hersleutelen blijft de datum wel behouden.
+
+import db from '../src/config/database.js';
+import { migrateReactions } from '../src/services/ActivityPubService.js';
+
+const dryRun = process.argv.includes('--dry-run');
+const force = process.argv.includes('--force');
+
+const meet = () => ({
+  tussentabel: db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions').get().n,
+  liked: db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE liked = 1').get().n,
+  boosted: db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE boosted = 1').get().n,
+  scheef: db.prepare(`
+    SELECT COUNT(*) AS n FROM ap_timeline t
+     WHERE (t.liked = 1 OR t.boosted = 1)
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = t.slug AND r.target_uri = t.id)`).get().n,
+  // Een reactie op een COMMENT hoort geen tijdlijnrij te hebben, dus die telt
+  // hier niet als wees -- anders slaat de controle hieronder alarm op precies
+  // wat stap 3 net goed heeft gezet.
+  wees: db.prepare(`
+    SELECT COUNT(*) AS n FROM ap_my_reactions r
+     WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t
+                        WHERE t.slug = r.site_slug AND t.id = r.target_uri)
+       AND NOT EXISTS (SELECT 1 FROM ap_interactions i WHERE i.object_uri = r.target_uri)`).get().n,
+  scheefActed: db.prepare(`
+    SELECT COUNT(*) AS n FROM ap_interactions i
+     JOIN posts p ON p.id = i.post_id
+     JOIN sites s ON s.id = p.site_id
+     WHERE (i.acted_like = 1 OR i.acted_boost = 1) AND IFNULL(i.object_uri, '') <> ''
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri)`).get().n,
+});
+
+const voor = meet();
+console.log('vooraf :', JSON.stringify(voor));
+
+const uit = migrateReactions({ dryRun, force: force || dryRun });
+if (uit.overgeslagen) {
+  console.log('\novergeslagen: de versievlag staat al. Gebruik --force om toch te draaien.');
+  process.exit(0);
+}
+if (dryRun) {
+  console.log(`\n--dry-run: zou ${uit.hersleuteld} rij(en) hersleutelen, ${uit.aangevuld} aanvullen`
+    + ` en ${uit.reacties} uit reacties overnemen. Niets geschreven.`);
+  process.exit(0);
+}
+
+const na = meet();
+console.log('hersleuteld:', uit.hersleuteld, ' aangevuld:', uit.aangevuld, ' uit reacties:', uit.reacties);
+console.log('achteraf   :', JSON.stringify(na));
+
+// De twee controles die tellen. Blijft er een vlag zonder tegenhanger, dan is de
+// tussentabel niet compleet en tonen reacties als niet-gegeven. Blijft er een
+// tussentabel-rij zonder tijdlijnrij, dan is die op iets buiten je tijdlijn
+// gericht (legitiem) OF nog op een permalink (niet legitiem) -- vandaar de
+// waarschuwing in plaats van een fout.
+if (na.scheef !== 0) {
+  console.error(`\nFOUT: nog ${na.scheef} rij(en) met een vlag zonder tegenhanger.`);
+  process.exit(1);
+}
+if (na.scheefActed !== 0) {
+  console.error(`\nFOUT: nog ${na.scheefActed} reactie(s) onder je eigen posts met acted_* zonder tegenhanger.`);
+  process.exit(1);
+}
+if (na.wees > voor.wees) {
+  console.error('\nFOUT: er zijn tussentabel-rijen bijgekomen die nergens op slaan.');
+  process.exit(1);
+}
+if (na.wees) {
+  console.warn(`\nLET OP: ${na.wees} tussentabel-rij(en) zonder tijdlijnrij. Dat mag (een reactie op iets\n`
+    + 'buiten je tijdlijn), maar controleer of er geen permalinks tussen zitten van een post die je\n'
+    + 'wel kent -- die zouden hersleuteld moeten zijn.');
+}
+console.log('\nOK: elke vlag heeft een tegenhanger in de tussentabel.');
Index: scripts/export-archive.mjs
===================================================================
--- scripts/export-archive.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ scripts/export-archive.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+//
+// Exporteer het inhoudsarchief van een site (shaer-1a6).
+//
+//     node scripts/export-archive.mjs <slug> --dry-run
+//     node scripts/export-archive.mjs <slug> --out boiert.zip
+//     node scripts/export-archive.mjs <slug> --dir ./archief
+//
+// Het formaat staat in docs/EXPORT-FORMAT.md.
+//
+// Dit is NIET de storage-zip: er zit geen sleutel, sessie, wachtwoordhash of
+// DM van een ander in. Wel je eigen concepten, en -- als je die hebt -- de
+// volledige inhoud van betaalde posts. Behandel het bestand als de inhoud zelf.
+
+import fs from 'fs';
+import path from 'path';
+import { buildArchive, zipArchive, writeArchiveDir } from '../src/services/ArchiveExportService.js';
+
+const args = process.argv.slice(2);
+const slug = args.find((a) => !a.startsWith('-'));
+const vlag = (naam) => { const i = args.indexOf(naam); return i >= 0 ? (args[i + 1] || true) : null; };
+
+if (!slug) {
+  console.error('gebruik: node scripts/export-archive.mjs <slug> [--dry-run | --out <zip> | --dir <map>]');
+  process.exit(1);
+}
+
+const uit = buildArchive(slug);
+const t = uit.counts;
+console.log(`site      : ${uit.manifest.site.slug} (${uit.manifest.origin || 'geen PUBLIC_BASE_URL'})`);
+console.log(`posts     : ${t.posts}`);
+console.log(`antwoorden: ${t.replies}   (alleen-lezen archief)`);
+console.log(`media     : ${t.media} meegenomen, ${t.mediaMissing} ontbrekend`);
+
+// Ontbrekende media worden GETELD en GEMELD, nooit stil overgeslagen: een post
+// waarvan het plaatje verdwenen is hoort dat te zeggen.
+if (uit.missing.length) {
+  console.log('\nontbrekende media:');
+  for (const m of uit.missing.slice(0, 20)) console.log(`  ${m.post}  ${m.url}`);
+  if (uit.missing.length > 20) console.log(`  ... en nog ${uit.missing.length - 20}`);
+}
+
+if (args.includes('--dry-run')) {
+  console.log(`\n--dry-run: ${uit.files.size} bestand(en) zouden geschreven worden. Niets geschreven.`);
+  process.exit(0);
+}
+
+const dir = vlag('--dir');
+if (dir && typeof dir === 'string') {
+  writeArchiveDir(uit.files, dir);
+  console.log(`\ngeschreven naar ${path.resolve(dir)}`);
+  process.exit(0);
+}
+
+const out = (typeof vlag('--out') === 'string' ? vlag('--out') : null) || `${slug}-archief.zip`;
+fs.writeFileSync(out, zipArchive(uit.files));
+console.log(`\ngeschreven: ${out} (${fs.statSync(out).size} bytes)`);
Index: scripts/import-archive.mjs
===================================================================
--- scripts/import-archive.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ scripts/import-archive.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+//
+// Importeer een inhoudsarchief in een site (shaer-pmr).
+//
+//     node scripts/import-archive.mjs <slug> <archief.zip|map> --dry-run
+//     node scripts/import-archive.mjs <slug> <archief.zip|map>
+//     node scripts/import-archive.mjs <slug> <archief.zip|map> --overwrite
+//
+// Het formaat staat in docs/EXPORT-FORMAT.md.
+//
+// Standaard wordt een bestaande post met hetzelfde id of dezelfde slug
+// OVERGESLAGEN. Dat maakt de import idempotent en zorgt dat je nooit per ongeluk
+// inhoud vernietigt die er al staat. --overwrite doet het wel, expliciet.
+//
+// Er gaat GEEN Update de fediverse in. Als andere servers een oudere kopie
+// hebben, blijft die daar staan; dat rechttrekken is een uitzending naar iedereen
+// en hoort een aparte, bewuste actie te zijn.
+
+import { readArchive, importArchive } from '../src/services/ArchiveImportService.js';
+
+const args = process.argv.slice(2);
+const vrij = args.filter((a) => !a.startsWith('-'));
+const [slug, bron] = vrij;
+
+if (!slug || !bron) {
+  console.error('gebruik: node scripts/import-archive.mjs <slug> <archief.zip|map> [--dry-run] [--overwrite]');
+  process.exit(1);
+}
+
+let files;
+try { files = readArchive(bron); }
+catch (e) { console.error(`kan het archief niet lezen: ${e.message}`); process.exit(1); }
+
+let r;
+try { r = importArchive(files, { slug, dryRun: args.includes('--dry-run'), overwrite: args.includes('--overwrite') }); }
+catch (e) { console.error(`geweigerd: ${e.message}`); process.exit(1); }
+
+console.log(`formaatversie : ${r.formatVersion}`);
+console.log(`herkomst      : ${r.origin || '(geen)'}`);
+console.log(`AP-ids        : ${r.idsBehouden ? 'BEHOUDEN (zelfde origin)' : 'nieuw (andere origin)'}`);
+console.log(`posts         : ${r.posts}${r.overschreven ? `, waarvan ${r.overschreven} overschreven` : ''}`);
+console.log(`overgeslagen  : ${r.overgeslagen}   (bestonden al)`);
+console.log(`antwoorden    : ${r.replies}   (alleen-lezen archief)`);
+console.log(`media         : ${r.media} teruggezet, ${r.mediaMissing} ontbrekend`);
+
+if (r.gemist.length) {
+  console.log('\nontbrekende media:');
+  for (const m of r.gemist.slice(0, 20)) console.log(`  ${m.post}  ${m.url}`);
+  if (r.gemist.length > 20) console.log(`  ... en nog ${r.gemist.length - 20}`);
+}
+for (const w of r.waarschuwingen) console.log(`\nLET OP: ${w}`);
+
+if (args.includes('--dry-run')) console.log('\n--dry-run: niets geschreven.');
Index: scripts/klonkt-refresh-updater.sh
===================================================================
--- scripts/klonkt-refresh-updater.sh	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ scripts/klonkt-refresh-updater.sh	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -30,5 +30,14 @@
 D="${KLONKT_DIR}"
 B=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD 2>/dev/null || true)
-runuser -u ${KLONKT_USER} -- git -C "\$D" fetch --depth 1 origin ${BRANCH}
+# Deepen a shallow checkout ONCE. \`fetch --depth 1\` keeps only the newest commit
+# and \`checkout -f\` throws away the tree that was there, so after an update the
+# previous version existed nowhere on the machine: a bad release could not be
+# undone without the network, and only if you knew which commit to ask for.
+# One deepening buys back the history; every later fetch keeps it.
+if [ "\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then
+  echo "deepening the checkout once so updates can be rolled back..."
+  runuser -u ${KLONKT_USER} -- git -C "\$D" fetch --unshallow origin ${BRANCH} || true
+fi
+runuser -u ${KLONKT_USER} -- git -C "\$D" fetch origin ${BRANCH}
 runuser -u ${KLONKT_USER} -- git -C "\$D" checkout -qf -B ${BRANCH} FETCH_HEAD
 A=\$(runuser -u ${KLONKT_USER} -- git -C "\$D" rev-parse HEAD)
@@ -54,4 +63,13 @@
 else
   echo "Klonkt updated (\$A) + restarted \$N instance(s)."
+fi
+# History alone is not a rollback; at three in the morning you also need the
+# command. Print it while the previous commit is still known.
+if [ -n "\$B" ]; then
+  echo
+  echo "Previous version: \$B"
+  echo "To go back:"
+  echo "  runuser -u ${KLONKT_USER} -- git -C \$D checkout -qf -B ${BRANCH} \$B"
+  echo "  then restart the instances (systemctl restart 'klonkt@*')"
 fi
 EOF
Index: scripts/recover-from-cache.mjs
===================================================================
--- scripts/recover-from-cache.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ scripts/recover-from-cache.mjs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+//
+// Herstel de posts van een verloren Klonkt uit de tijdlijn-cache van instances
+// die haar volgden (shaer-l1v).
+//
+//     node scripts/recover-from-cache.mjs \
+//       --actor https://boiert.eu/ap/users/boiert \
+//       --from /pad/naar/sound-fabrics/database.sqlite \
+//       --from /pad/naar/nog-een/database.sqlite \
+//       --media /geredde/storage/media \
+//       --audio /geredde/storage/audio \
+//       --out boiert-herstel.zip
+//
+// Het resultaat is een gewoon archief in het formaat uit docs/EXPORT-FORMAT.md.
+// Terugzetten gaat dus met scripts/import-archive.mjs, met dezelfde droogloop en
+// dezelfde regel rond het behouden van AP-ids. Er is bewust geen apart
+// herstelpad: dat zou een tweede implementatie zijn van iets dat al bestaat.
+//
+// WAT HIER PRINCIPIEEL NIET IN ZIT:
+//   - antwoorden van de verloren site zelf (die staan niet in een tijdlijn-cache)
+//   - alles van voor het moment dat de bron ging volgen
+//   - concepten (nooit gefedereerd, dus nergens gecachet)
+//   - de plek van afbeeldingen IN de tekst (bij het federeren eruit gehaald)
+
+import fs from 'fs';
+import path from 'path';
+import { recoverFromCache } from '../src/services/ArchiveRecoveryService.js';
+import { zipArchive } from '../src/services/ArchiveExportService.js';
+
+const args = process.argv.slice(2);
+const alle = (naam) => args.reduce((uit, a, i) => (a === naam && args[i + 1] ? [...uit, args[i + 1]] : uit), []);
+const een = (naam) => alle(naam)[0] || null;
+
+const actor = een('--actor');
+const sources = alle('--from');
+if (!actor || !sources.length) {
+  console.error('gebruik: node scripts/recover-from-cache.mjs --actor <actor-uri> --from <db> [--from <db>] [--media <map>] [--audio <map>] [--out <zip> | --dir <map>] [--houd-titel]');
+  process.exit(1);
+}
+
+let uit;
+try {
+  uit = recoverFromCache({
+    sources, actorUri: actor, mediaRoot: een('--media'), audioRoot: een('--audio'),
+    houdTitelInTekst: args.includes('--houd-titel'),
+    slug: een('--slug') || '', title: een('--titel') || '',
+  });
+} catch (e) { console.error(`kan niet herstellen: ${e.message}`); process.exit(1); }
+
+const r = uit.rapport;
+console.log('bronnen:');
+for (const b of r.bronnen) console.log(`  ${b.rijen.toString().padStart(5)} rij(en)  ${b.pad}`);
+console.log(`\nposts gevonden : ${r.posts}`);
+console.log(`periode        : ${r.oudste || '?'}  tot  ${r.nieuwste || '?'}`);
+console.log(`media          : ${r.media} gered, ${r.mediaMissing} niet gevonden`);
+console.log(`titels          : ${r.titels.length} losgetrokken uit de tekst`);
+
+// De titels horen door een MENS nagelopen te worden. Er is geen sluitend signaal
+// dat een vetgedrukte eerste regel een titel was; de slug is er niet van afgeleid.
+if (r.titels.length) {
+  console.log('\nteruggevonden titels -- loop deze na, een post die echt met een vetgedrukte');
+  console.log('regel begint raakt die regel kwijt aan zijn titel:');
+  for (const t of r.titels.slice(0, 30)) console.log(`  ${t.slug.padEnd(28)} ${t.titel}`);
+  if (r.titels.length > 30) console.log(`  ... en nog ${r.titels.length - 30}`);
+}
+if (r.gemist.length) {
+  console.log('\nmedia niet gevonden op schijf:');
+  for (const m of r.gemist.slice(0, 20)) console.log(`  ${m.slug.padEnd(28)} ${m.url}`);
+  if (r.gemist.length > 20) console.log(`  ... en nog ${r.gemist.length - 20}`);
+}
+for (const w of r.waarschuwingen) console.log(`\nLET OP: ${w}`);
+
+console.log('\nNIET herstelbaar uit een tijdlijn-cache: eigen antwoorden, alles van voor het');
+console.log('volgmoment, concepten, en de plek van afbeeldingen in de tekst.');
+
+if (args.includes('--dry-run')) {
+  console.log(`\n--dry-run: ${uit.files.size} bestand(en) zouden geschreven worden. Niets geschreven.`);
+  process.exit(0);
+}
+
+const dir = een('--dir');
+if (dir) {
+  for (const pad of [...uit.files.keys()].sort()) {
+    const doel = path.join(dir, pad);
+    fs.mkdirSync(path.dirname(doel), { recursive: true });
+    fs.writeFileSync(doel, uit.files.get(pad));
+  }
+  console.log(`\ngeschreven naar ${path.resolve(dir)}`);
+  process.exit(0);
+}
+
+const out = een('--out') || 'herstel.zip';
+fs.writeFileSync(out, zipArchive(uit.files));
+console.log(`\ngeschreven: ${out} (${fs.statSync(out).size} bytes)`);
+console.log(`\nterugzetten:  node scripts/import-archive.mjs <slug> ${out} --dry-run`);
Index: src/assets/css/guardian.css
===================================================================
--- src/assets/css/guardian.css	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/assets/css/guardian.css	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -168,2 +168,30 @@
 .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; }
Index: src/assets/js/guardian.js
===================================================================
--- src/assets/js/guardian.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/assets/js/guardian.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -49,4 +49,18 @@
   // 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; }
 
   function helpCard(h) {
@@ -72,5 +86,80 @@
       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, ageMs: null };
+    var box = el('div', 'g-help-state');
+
+    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'.
+    if (st.ageMs != null && st.ageMs > 3600000) {
+      box.appendChild(el('div', 'g-help-age small', ago(st.ageMs)));
+    }
+
+    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; });
   }
 
@@ -78,12 +167,40 @@
     var list = document.getElementById('help-list');
     list.textContent = '';
-    var help = S.help || [];
-    help.slice(0, HELP_TOP).forEach(function (h) { list.appendChild(helpCard(h)); });
-    if (help.length > HELP_TOP) {
-      list.appendChild(el('p', 'g-sec-sub', '+ ' + (help.length - HELP_TOP) + ' — ' + (T.panel_help || '')));
-    }
+    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 = help.length; badge.hidden = help.length === 0;
-    show('help-empty', help.length === 0);
+    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);
   }
 
@@ -351,4 +468,64 @@
   }
 
+  /** 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.
+   */
+  function gateRow(w, g) {
+    var row = el('div', 'g-gate');
+    var head = el('div', 'g-gate-head');
+    head.appendChild(el('span', 'g-gate-name', gateLabel(g.feature)));
+    head.appendChild(el('span', 'g-gate-kind', T['gate_kind_' + g.kind] || g.kind));
+    row.appendChild(head);
+
+    // De stand. NULL is onbekend en dat is iets anders dan uit: bij een ward op
+    // een andere server wordt die kolom daar bijgehouden.
+    var stand = g.value === null || g.value === undefined
+      ? (T.gate_unknown || 'unknown')
+      : (g.value ? (T.prop_on || 'on') : (T.prop_off || 'off'));
+    var meta = el('div', 'g-gate-meta small');
+    meta.appendChild(el('span', null, stand));
+    if (g.threshold) {
+      meta.appendChild(el('span', null, (T.gate_threshold || '{need} of {of} guardians')
+        .replace('{need}', g.threshold.need).replace('{of}', g.threshold.of)));
+    } else {
+      // 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 || ''));
+    if (g.waiting) {
+      meta.appendChild(el('span', 'g-gate-wait', (T.gate_waiting || '{n} waiting').replace('{n}', g.waiting)));
+    }
+    row.appendChild(meta);
+
+    if (g.proposal) {
+      row.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) {
+      row.appendChild(el('p', 'small g-empty', (T.gate_blocked || 'needs {what} first')
+        .replace('{what}', gateLabel(g.blockedBy))));
+    }
+    if (g.adjustable) {
+      row.appendChild(gateButton(w, g.feature, g.value, T.gate_propose || T.embeds_propose,
+        T.prop_on || 'on', T.prop_off || 'off'));
+    }
+    return row;
+  }
+
   function wardPanel(w) {
     var uri = w.other_uri;
@@ -358,28 +535,25 @@
     var set = el('div', 'g-panel-sec');
     set.appendChild(el('h3', null, T.settings_title || 'Settings'));
-    var setRow = el('div', 'row');
-    setRow.appendChild(gateButton(w, 'shaer:externalEmbeds', w.embeds, T.embeds_propose, T.embeds_on, T.embeds_off));
-    // The heavier sibling (5.6): seeing that a video exists is one decision,
-    // letting a third party's player run inside the app is another. Hidden
-    // only when previews are known-OFF: for a ward on another server the
-    // value is unknown, and unknown is not off. Hiding it there meant a
-    // guardian elsewhere could never even propose playback, which is how a
-    // whole proposal round went into the wrong gate. The ward's server
-    // enforces play-needs-previews at serve time regardless.
-    if (w.embeds !== false) {
-      setRow.appendChild(gateButton(w, 'shaer:externalPlayback', w.playback, T.play_propose, T.play_on, T.play_off));
-    }
-    set.appendChild(setRow);
-    // What this guardian proposed and how it stands (5.6). This used to be a
-    // button caption that vanished on refresh, so a running decision was
-    // invisible: you could not tell "waiting", "done" and "expired" apart.
-    (w.proposals || []).forEach(function (p) {
-      var what = p.feature === 'shaer:externalPlayback' ? (T.prop_play || 'playback') : (T.prop_embeds || 'link previews');
-      var line = (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);
-      set.appendChild(el('p', 'small g-prop g-prop-' + p.status, line));
-    });
+    // 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".
+    (w.gates || []).forEach(function (g) { set.appendChild(gateRow(w, g)); });
+    // 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);
 
@@ -420,6 +594,10 @@
       T.panel_follow_empty || '', followCard);
 
+    // Hier juist de VOLLEDIGE geschiedenis van dit kind -- dat is waar dit
+    // paneel voor is. Wel wat nog wacht bovenaan, want dat is waar je naar zoekt
+    // als je het opent.
     sectionInto(panel, T.panel_help || 'Calls for help',
-      (S.help || []).filter(function (h) { return h.actor_uri === uri; }),
+      (S.help || []).filter(function (h) { return h.actor_uri === uri; })
+        .sort(function (a, b) { return (helpOpen(b) ? 1 : 0) - (helpOpen(a) ? 1 : 0); }),
       T.panel_help_empty || '', helpCard);
 
@@ -453,5 +631,9 @@
     actRow.appendChild(rel);
     act.appendChild(actRow);
-    panel.appendChild(act);
+    // 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;
   }
@@ -467,5 +649,7 @@
       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.
-      var nHelp = (S.help || []).filter(function (h) { return h.actor_uri === uri; }).length;
+      // 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));
@@ -553,11 +737,17 @@
     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 = p.content || ''; d.appendChild(inner);
+      var inner = el('div'); inner.innerHTML = html; d.appendChild(inner);
       body.appendChild(d);
     } else {
-      body.innerHTML = p.content || '';   // server-sanitized HTML (same as Berichten)
+      body.innerHTML = html;   // server-sanitized HTML (same as Berichten)
     }
     card.appendChild(body);
@@ -700,5 +890,19 @@
 
   renderAll(); pushState(); loadFeed(); loadFollowReqs();
-  setInterval(refresh, 45000);   // live-ish while open
+
+  // 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.
+  setInterval(function () { if (!document.hidden) refresh(); }, 45000);
+  document.addEventListener('visibilitychange', function () { if (!document.hidden) refresh(); });
   } catch (e) {
     fatal((e && e.message) || String(e));
Index: src/assets/js/mod/admin-audio.js
===================================================================
--- src/assets/js/mod/admin-audio.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-audio.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,442 @@
+// 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 } from './lib.js';
+
+  const T = pageData();
+
+(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 => {
+    window.addEventListener(ev, e => {
+      // Allow drops INSIDE the dropzone — its own listener handles those.
+      if (dropzone.contains(e.target)) return;
+      e.preventDefault();
+    });
+  });
+
+  ['dragenter', 'dragover'].forEach(ev => {
+    dropzone.addEventListener(ev, e => {
+      e.preventDefault();
+      dropzone.classList.add('is-dragover');
+    });
+  });
+  ['dragleave', 'drop'].forEach(ev => {
+    dropzone.addEventListener(ev, e => {
+      e.preventDefault();
+      dropzone.classList.remove('is-dragover');
+    });
+  });
+  dropzone.addEventListener('drop', e => {
+    if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
+  });
+  fileInput.addEventListener('change', () => {
+    addFiles(fileInput.files);
+    // Reset so the same file can be picked again later if user wants
+    fileInput.value = '';
+  });
+
+  // ── Clear queue button ───────────────────────────────────────
+  clearBtn.addEventListener('click', () => {
+    // Only remove items that aren't currently uploading (anything queued
+    // or already finished). An in-progress upload finishes, then its row
+    // would also disappear once we re-render — but we keep it simple and
+    // just refuse to clear during an active run.
+    if (startBtn.disabled) return;
+    queue.length = 0;
+    queueEl.innerHTML = '';
+    queueEl.hidden = true;
+    actionsEl.hidden = true;
+  });
+
+  // ── Sequential upload loop ───────────────────────────────────
+  startBtn.addEventListener('click', async () => {
+    if (startBtn.disabled) return;
+    startBtn.disabled = true;
+    clearBtn.disabled = true;
+    dropzone.style.pointerEvents = 'none';
+    dropzone.style.opacity = '0.5';
+
+    const sharedArtist = artistInput.value.trim();
+    const sharedAlbum  = albumInput.value.trim();
+    const sharedCover  = coverInput.files && coverInput.files[0];
+
+    // Process queued items one at a time. We iterate via index so that
+    // if more files get dropped during the run they ALSO get processed
+    // (queue.push above mutates the same array we're iterating).
+    for (let i = 0; i < queue.length; i++) {
+      const item = queue[i];
+      if (item.status !== 'queued') continue;
+      try {
+        await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
+      } catch (err) {
+        console.error('upload failed for', item.file.name, err);
+        setItemStatus(item, 'error', '✗ ' + (err.message || (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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-epk.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,21 @@
+// 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.
+
+(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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-help.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,30 @@
+// 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.
+
+(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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-media.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,38 @@
+// 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;
+  var T = pageData();
+  document.addEventListener('click', function (e) {
+    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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-paid.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,21 @@
+// 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.
+
+(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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-playlists.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,75 @@
+// 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';
+
+(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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-push.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,110 @@
+// 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.
+
+(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-settings.js
===================================================================
--- src/assets/js/mod/admin-settings.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-settings.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,20 @@
+// 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.
+
+    (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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-site-edit.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,130 @@
+// 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.
+
+    (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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/admin-videos.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,30 @@
+// 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;
+  var T = pageData();
+  document.addEventListener('click', function (e) {
+    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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/auth-register.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,19 @@
+// 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.
+
+(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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/authorize-interaction.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,61 @@
+// 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.
+
+    (function () {
+      if (window.__fediBmWired) return; window.__fediBmWired = true;
+      var a = document.getElementById('fedi-bm-btn'); if (!a) return;
+      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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/chrome.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,284 @@
+// 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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/download.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,11 @@
+// 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';
+
+        // Auto-start de download (zelfde-origin attachment-link).
+        setTimeout(function(){ try { window.location.href = (pageData().fileUrl || ''); } catch(e){} }, 600);
+      
Index: src/assets/js/mod/lib.js
===================================================================
--- src/assets/js/mod/lib.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/lib.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,48 @@
+// 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;
+}
+
+/**
+ * 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];
+  });
+}
Index: src/assets/js/mod/messages.js
===================================================================
--- src/assets/js/mod/messages.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/messages.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,132 @@
+// 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);
+    });
+  }
+
+  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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/news.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,129 @@
+// 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';
+
+  (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. */
+(function(){
+  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 });
+  });
+})();
+
+/* 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.
+(function(){
+  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);
+    }
+  });
+})();
Index: src/assets/js/mod/paid-gate.js
===================================================================
--- src/assets/js/mod/paid-gate.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/paid-gate.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,61 @@
+// 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 } from './lib.js';
+
+(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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/paid-passkey.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,41 @@
+// 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 } from './lib.js';
+
+(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 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/playlist-editor.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,412 @@
+// 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';
+
+(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('Tracks ophalen mislukt');
+      return;
+    }
+    if (!tracks.length) {
+      alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
+      return;
+    }
+
+    // Existing playlist data when editing
+    let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };
+    if (isEdit && opts.id) {
+      try {
+        const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
+        if (j.ok) initial = { ...initial, ...j.playlist };
+      } catch (e) {}
+    }
+
+    // ── DOM ────────────────────────────────────────────────────────
+    const backdrop = document.createElement('div');
+    backdrop.className = 'pl-modal-backdrop';
+    backdrop.innerHTML = `
+      <div class="pl-modal" role="dialog" aria-label="Playlist editor">
+        <div class="pl-modal-header">
+          <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>
+          <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button>
+        </div>
+        <div class="pl-modal-body">
+          <div class="pl-editor-cols">
+            <div class="pl-editor-left">
+              <div class="pl-meta-grid">
+                <label class="pl-field pl-field-full">
+                  <span>Titel *</span>
+                  <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
+                </label>
+                <label class="pl-field">
+                  <span>Artiest</span>
+                  <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
+                </label>
+                <label class="pl-field">
+                  <span>Jaar</span>
+                  <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
+                </label>
+                <label class="pl-field">
+                  <span>Type</span>
+                  <select id="pli-kind">
+                    <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>
+                    <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>
+                  </select>
+                </label>
+                <div class="pl-field pl-field-full">
+                  <span>Cover</span>
+                  <div class="pl-cover-row">
+                    <span class="pl-cover-thumb" id="pli-cover-thumb">
+                      ${initial.cover
+                        ? `<img src="${esc(initial.cover)}" alt="">`
+                        : `<span class="pl-cover-empty">🎨</span>`}
+                    </span>
+                    <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
+                  </div>
+                  <div class="pl-cover-upload-row">
+                    <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
+                    <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
+                    <span class="pl-cover-status" id="pli-cover-status"></span>
+                  </div>
+                </div>
+              </div>
+              <div class="pl-section-title">
+                Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
+                <small>(sleep ⠿ om te ordenen)</small>
+              </div>
+              <div id="pli-selected" class="pl-selected-list"></div>
+            </div>
+            <div class="pl-editor-right">
+              <div class="pl-section-title">Beschikbare tracks</div>
+              <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
+              <div id="pli-available" class="pl-available-list"></div>
+            </div>
+          </div>
+        </div>
+        <div class="pl-modal-footer">
+          <button type="button" class="btn" id="pli-cancel">Annuleren</button>
+          <button type="button" class="btn btn-primary" id="pli-save" disabled>
+            ${isEdit ? 'Opslaan' : 'Aanmaken'}
+          </button>
+        </div>
+      </div>
+    `;
+    document.body.appendChild(backdrop);
+
+    const $ = sel => backdrop.querySelector(sel);
+    const close = () => backdrop.remove();
+    backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
+    $('.pl-modal-close').addEventListener('click', close);
+    $('#pli-cancel').addEventListener('click', close);
+
+    // Index tracks by id for fast lookup
+    const trackById = new Map(tracks.map(t => [t.id, t]));
+    let selected = (initial.track_ids || []).filter(id => trackById.has(id));
+
+    const titleEl = $('#pli-title');
+    const artistEl = $('#pli-artist');
+    const yearEl = $('#pli-year');
+    const kindEl = $('#pli-kind');
+    const coverEl = $('#pli-cover');
+    const coverThumb = $('#pli-cover-thumb');
+    const saveBtn = $('#pli-save');
+    const selectedEl = $('#pli-selected');
+    const availEl = $('#pli-available');
+    const searchEl = $('#pli-search');
+    const countEl = $('#pli-count');
+
+    coverEl.addEventListener('input', () => {
+      const u = coverEl.value.trim();
+      coverThumb.innerHTML = u
+        ? `<img src="${esc(u)}" alt="" 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 'Aanmaken'/'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 = 'Alleen afbeeldingen';
+          coverStatus.className = 'pl-cover-status is-error';
+          return;
+        }
+        coverStatus.textContent = 'Uploaden…';
+        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 || 'Upload mislukt');
+          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 = 'Mislukt: ' + err.message;
+          coverStatus.className = 'pl-cover-status is-error';
+        }
+      });
+    }
+
+    function updateSaveBtn() {
+      saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
+    }
+
+    function renderSelected() {
+      countEl.textContent = selected.length;
+      if (selected.length === 0) {
+        selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
+        updateSaveBtn();
+        return;
+      }
+      selectedEl.innerHTML = selected.map((id, i) => {
+        const t = trackById.get(id);
+        if (!t) return '';
+        const cover = t.cover
+          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
+          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
+        return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
+          <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
+          <span class="pl-row-num">${i + 1}</span>
+          ${cover}
+          <span class="pl-row-info">
+            <span class="pl-row-title">${esc(t.title)}</span>
+            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
+          </span>
+          <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
+        </div>`;
+      }).join('');
+
+      selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
+        b.addEventListener('click', () => {
+          selected = selected.filter(x => x !== b.dataset.id);
+          renderSelected();
+          renderAvailable();
+        });
+      });
+      bindDrag();
+      updateSaveBtn();
+    }
+
+    function renderAvailable() {
+      const q = searchEl.value.trim().toLowerCase();
+      const matches = tracks.filter(t => {
+        if (!q) return true;
+        return (t.title || '').toLowerCase().includes(q)
+            || (t.artist || '').toLowerCase().includes(q);
+      });
+      if (matches.length === 0) {
+        availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
+        return;
+      }
+      availEl.innerHTML = matches.map(t => {
+        const isAdded = selected.includes(t.id);
+        const cls = ['pl-avail-row'];
+        if (isAdded) cls.push('is-added');
+        if (!t.playable) cls.push('is-unplayable');
+        const cover = t.cover
+          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
+          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
+        return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
+          ${cover}
+          <span class="pl-row-info">
+            <span class="pl-row-title">${esc(t.title)}</span>
+            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
+          </span>
+          <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
+        </div>`;
+      }).join('');
+
+      availEl.querySelectorAll('.pl-avail-row').forEach(row => {
+        if (row.classList.contains('is-unplayable')) return;
+        row.addEventListener('click', () => {
+          const id = row.dataset.id;
+          if (selected.includes(id)) selected = selected.filter(x => x !== id);
+          else selected.push(id);
+          renderSelected();
+          renderAvailable();
+        });
+      });
+    }
+
+    // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
+    function bindDrag() {
+      selectedEl.querySelectorAll('.pl-row').forEach(row => {
+        const handle = row.querySelector('.pl-row-handle');
+        if (!handle) return;
+        let dragging = false, originalIdx = -1;
+
+        handle.addEventListener('pointerdown', e => {
+          e.preventDefault();
+          handle.setPointerCapture(e.pointerId);
+          dragging = true;
+          originalIdx = parseInt(row.dataset.pos, 10);
+          row.classList.add('is-dragging');
+        });
+        handle.addEventListener('pointermove', e => {
+          if (!dragging) return;
+          e.preventDefault();
+          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
+          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
+          let targetIdx = -1, above = false;
+          for (let i = 0; i < rows.length; i++) {
+            const r = rows[i]; if (r === row) continue;
+            const rect = r.getBoundingClientRect();
+            const mid = rect.top + rect.height / 2;
+            if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
+            if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
+              targetIdx = i; above = e.clientY < mid; break;
+            }
+          }
+          if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
+        });
+        const finish = e => {
+          if (!dragging) return;
+          dragging = false;
+          try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
+          row.classList.remove('is-dragging');
+          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
+          let targetIdx = -1, above = false;
+          for (let i = 0; i < rows.length; i++) {
+            if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
+            if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
+          }
+          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
+          if (targetIdx === -1 || targetIdx === originalIdx) return;
+          const moved = selected[originalIdx];
+          selected.splice(originalIdx, 1);
+          let newIdx = targetIdx;
+          if (originalIdx < targetIdx) newIdx--;
+          if (!above) newIdx++;
+          newIdx = Math.max(0, Math.min(selected.length, newIdx));
+          selected.splice(newIdx, 0, moved);
+          renderSelected();
+        };
+        handle.addEventListener('pointerup', finish);
+        handle.addEventListener('pointercancel', () => {
+          dragging = false;
+          row.classList.remove('is-dragging');
+          selectedEl.querySelectorAll('.drop-above, .drop-below')
+            .forEach(r => r.classList.remove('drop-above', 'drop-below'));
+        });
+      });
+    }
+
+    titleEl.addEventListener('input', updateSaveBtn);
+    searchEl.addEventListener('input', renderAvailable);
+    renderSelected();
+    renderAvailable();
+
+    saveBtn.addEventListener('click', async () => {
+      if (saveBtn.disabled) return;
+      const orig = saveBtn.textContent;
+      saveBtn.disabled = true;
+      saveBtn.textContent = 'Opslaan...';
+
+      const payload = {
+        title:  titleEl.value.trim(),
+        artist: artistEl.value.trim(),
+        year:   parseInt(yearEl.value, 10) || 0,
+        cover:  coverEl.value.trim(),
+        kind:   kindEl.value === 'playlist' ? 'playlist' : 'album',
+        tracks: selected.slice(),
+      };
+
+      try {
+        const url = isEdit
+          ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
+          : '/admin/playlists/api';
+        const j = await api('POST', url, payload);
+        if (!j.ok) {
+          alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
+          saveBtn.disabled = false;
+          saveBtn.textContent = orig;
+          return;
+        }
+        const savedId = isEdit ? initial.id : j.id;
+        close();
+        if (typeof opts.onSaved === 'function') {
+          opts.onSaved({ id: savedId, playlist: payload });
+        }
+      } catch (err) {
+        alert('Opslaan mislukt: ' + err.message);
+        saveBtn.disabled = false;
+        saveBtn.textContent = orig;
+      }
+    });
+
+    // ESC to close
+    document.addEventListener('keydown', function onEsc(e) {
+      if (e.key === 'Escape' && document.body.contains(backdrop)) {
+        close();
+        document.removeEventListener('keydown', onEsc);
+      }
+    });
+  };
+})();
Index: src/assets/js/mod/post-edit.js
===================================================================
--- src/assets/js/mod/post-edit.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/post-edit.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,1098 @@
+// 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 } from './lib.js';
+
+const T = pageData();
+
+          (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');
+        l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', '');
+        document.head.appendChild(l);
+      }
+      const s = document.createElement('script');
+      s.src = '/assets/vendor/cropper.min.js';
+      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);
+
+  // 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) 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(); }
+  window.addEventListener('popstate', function () { if (isFs()) applyFs(false); });
+  if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
+  var fsDoneBtn = document.getElementById('pe-fs-done');
+  if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs);
+  document.addEventListener('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.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());
+  }
+  document.addEventListener('selectionchange', () => {
+    if (document.activeElement === editor) updateToolbarState();
+  });
+
+  // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
+  editor.addEventListener('keydown', (e) => {
+    const mod = e.ctrlKey || e.metaKey;
+    if (!mod) return;
+    const k = e.key.toLowerCase();
+    if (k === 'b') { e.preventDefault(); execCmd('bold'); }
+    else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
+    else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
+    else if (k === 'k') { e.preventDefault(); linkPrompt(); }
+  });
+
+  // Paste: keep it simple — strip formatting unless user wants it. Default
+  // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
+  // styles from clipboard only when shift is held — otherwise plain text.
+  editor.addEventListener('paste', (e) => {
+    if (e.shiftKey) return; // user wants formatted paste
+    const text = (e.clipboardData || window.clipboardData).getData('text/plain');
+    if (text == null) return;
+    e.preventDefault();
+    document.execCommand('insertText', false, text);
+  });
+
+  // ── Image upload (button + drag-drop into the editor)
+  async function uploadAndInsertImage(file) {
+    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();
+  }
+
+  // ── 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();
+        }
+      }
+    });
+    document.addEventListener('keydown', (e) => {
+      if (!trackPicker.hidden && e.key === 'Escape') {
+        e.preventDefault();
+        closePicker();
+      }
+    });
+
+    // Live filter
+    tpSearch.addEventListener('input', () => renderList(tpSearch.value));
+  }
+
+  // ── Playlist insert (open existing or create new via modal)
+  const playlistBtn = document.getElementById('insert-playlist-btn');
+  if (playlistBtn) {
+    playlistBtn.addEventListener('click', async () => {
+      if (typeof window.openPlaylistEditor !== 'function') {
+        alert('' + 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');
+
+    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');
+      });
+      panels.forEach(p => { p.hidden = (p.dataset.panel !== tt); });
+    }
+    seg.addEventListener('click', (e) => {
+      const btn = e.target.closest('.pe-typeseg-btn');
+      if (btn) applyType(btn.dataset.type);
+    });
+    applyType(typeInput.value || 'post');
+
+    // 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();
+  window.addEventListener('resize', position);
+  if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
+  // has-audio-player is toggled via a body class → observe it.
+  try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
+})();
Index: src/assets/js/mod/post.js
===================================================================
--- src/assets/js/mod/post.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/post.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,57 @@
+// 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/track-editor.js
===================================================================
--- src/assets/js/mod/track-editor.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/assets/js/mod/track-editor.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,482 @@
+// 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/config/database.js
===================================================================
--- src/config/database.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/config/database.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -106,4 +106,13 @@
     PRIMARY KEY (guardian_slug, id)
   )`);
+  // Guardianship Fase 2 (shaer-jdb): een doorgestuurde follow-goedkeuring draagt
+  // een RICHTING. Bij een inkomende is de follower iemand anders en de ward het
+  // doel; bij een uitgaande is de ward zelf de follower en staat het doel in het
+  // Follow-object. Zonder deze twee kolommen werd een uitgaande opgeslagen als
+  // "deze ward wil deze ward volgen" en viel het doel weg -- dan valt er niets
+  // zinnigs te tonen, hoe je de wachtrij ook vult.
+  ensureColumn('ap_follow_reviews', 'direction', "TEXT DEFAULT 'incoming'");
+  ensureColumn('ap_follow_reviews', 'target_uri', 'TEXT');
+  ensureColumn('ap_follow_reviews', 'target_handle', 'TEXT');
   ensureColumn('sites', 'profile_photo', 'TEXT');
   ensureColumn('audio_tracks', 'cover_url', 'TEXT');
@@ -469,4 +478,16 @@
       UNIQUE(slug, actor_uri)
     );
+    -- Antwoorden van accounts die we volgen komen gewoon binnen, ondertekend
+    -- door de schrijver, maar horen niet in de Krant (belongsInTimeline) en
+    -- werden daarna nergens bewaard. Kwam er later een doorgestuurd antwoord OP
+    -- zo'n bericht, dan kenden we de ouder niet en wezen we het af (shaer-e9g).
+    -- Alleen de URI, geen inhoud: dit voedt uitsluitend de vraag "kennen wij dit
+    -- bericht?". Wordt na 30 dagen gesnoeid; doorsturen gebeurt kort na het
+    -- antwoord, dus langer bewaren levert niets op.
+    CREATE TABLE IF NOT EXISTS ap_seen_notes (
+      uri TEXT PRIMARY KEY,
+      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+    );
+    CREATE INDEX IF NOT EXISTS idx_ap_seen_notes_age ON ap_seen_notes(created_at);
     CREATE TABLE IF NOT EXISTS ap_timeline (
       id TEXT NOT NULL,              -- the remote note's AP id
@@ -714,4 +735,26 @@
   ensureColumn('ap_outbox', 'to_actors', 'TEXT');   // JSON array of recipient actor URIs for direct notes
   ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
+  // Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
+  // Los van ap_mentions, want dit is GEDEELDE staat: elke guardian van dit kind
+  // heeft er een kopie van, en die komt binnen als bericht van een ander. Een
+  // kolom op de mention zou alleen over onszelf gaan.
+  //
+  // OPGEPIKT mag stapelen: twee mensen die tegelijk reageren op een kind dat om
+  // hulp vraagt is geen probleem. Twee mensen die allebei niets doen omdat de
+  // ander het "geclaimd" had, wel.
+  //
+  // AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
+  // bevestiging, en leeft de vraag daarna nog, dan wordt hij opnieuw gesteld --
+  // een nieuwe hulpvraag. Zo blijft het verslag eerlijk: er wordt niets
+  // herschreven, er wordt toegevoegd.
+  db.exec(`CREATE TABLE IF NOT EXISTS ap_help_state (
+    note_uri TEXT NOT NULL,
+    guardian_uri TEXT NOT NULL,
+    kind TEXT NOT NULL,                 -- pickup | handled
+    guardian_handle TEXT,
+    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (note_uri, guardian_uri, kind)
+  )`);
+  db.exec('CREATE INDEX IF NOT EXISTS idx_ap_help_state_note ON ap_help_state(note_uri)');
   ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
   ensureColumn('ap_outbox', 'wave', 'INTEGER');    // FEP-633c shaer:wave (guardian -> ward nudge)
@@ -759,4 +802,85 @@
   ensureColumn('ap_followers', 'handle', 'TEXT');  // @user@host
   ensureColumn('ap_followers', 'icon', 'TEXT');    // avatar URL
+  feedStateTriggers();
+}
+
+/**
+ * Wat er met een tijdlijn gebeurd is, op één plek (shaer-n05).
+ *
+ * De inbox-lezing voegt vier bronnen samen. De vraag "is er iets veranderd" werd
+ * eerst beantwoord met MAX(rowid) over die vier -- een TOEVALLIGE eigenschap van
+ * de tabellen, geen feit dat ergens is opgeschreven. Dat gaf precies de gebreken
+ * die je van zo'n afleiding verwacht: bewerkingen en verwijderingen bewogen hem
+ * niet, en hij kon achteruit lopen. Dezelfde fout als reacties uitlezen uit
+ * ap_timeline.liked (shaer-9e9).
+ *
+ * Nu één rij per bericht per tijdlijn, met een oplopende `rev` en `kind`. Dat
+ * beantwoordt drie vragen die anders drie eigen oplossingen zouden krijgen:
+ * is er iets veranderd sinds N, wát is er veranderd, en is dit bericht bewerkt.
+ *
+ * Bijgehouden door TRIGGERS en niet door de aanroepende code, om dezelfde reden
+ * dat er geen gebeurtenis-emitter is: een trigger zit in de database, dus geen
+ * enkel codepad kan hem vergeten. De prijs is onzichtbare logica -- wie alleen de
+ * JavaScript leest ziet niet waarom deze tabel vult. Vandaar dat ze hier staan,
+ * bij de tabel, en niet verspreid.
+ *
+ * Let op de `UPDATE OF`-kolomlijsten: die zijn niet decoratief. Een like schrijft
+ * ap_timeline.liked en een 🔁 schrijft .boosted; zonder die afbakening zou je
+ * eigen like het bericht als BEWERKT merken en elke wachtende client wekken.
+ */
+function feedStateTriggers() {
+  try {
+    db.exec(`
+      CREATE TABLE IF NOT EXISTS ap_feed_state (
+        slug TEXT NOT NULL,
+        object_uri TEXT NOT NULL,
+        rev INTEGER NOT NULL,
+        kind TEXT NOT NULL,             -- new | updated | deleted
+        at DATETIME DEFAULT CURRENT_TIMESTAMP,
+        PRIMARY KEY (slug, object_uri)
+      );
+      CREATE INDEX IF NOT EXISTS idx_ap_feed_state_rev ON ap_feed_state(slug, rev);
+      -- Eén doorlopende teller voor de hele instance. Bewust niet MAX(rev) uit de
+      -- tabel zelf: verdwijnt de hoogste rij, dan zou die teruglopen en denkt een
+      -- client dat er niets gebeurd is.
+      CREATE TABLE IF NOT EXISTS ap_feed_rev (n INTEGER NOT NULL);
+    `);
+    if (!db.prepare('SELECT COUNT(*) AS n FROM ap_feed_rev').get().n) {
+      db.prepare('INSERT INTO ap_feed_rev (n) VALUES (0)').run();
+    }
+    // slug + object_uri verschillen per bron; de rest is voor alle vier gelijk.
+    const zet = (naam, gebeurtenis, tabel, slug, uri, kind, extra = '') => `
+      DROP TRIGGER IF EXISTS ${naam};
+      CREATE TRIGGER ${naam} AFTER ${gebeurtenis} ON ${tabel} BEGIN
+        UPDATE ap_feed_rev SET n = n + 1;
+        INSERT INTO ap_feed_state (slug, object_uri, rev, kind)
+          ${extra || `VALUES (${slug}, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}')`}
+          ON CONFLICT(slug, object_uri) DO UPDATE
+            SET rev = excluded.rev, kind = excluded.kind, at = CURRENT_TIMESTAMP;
+      END;`;
+    const joinPosts = (uri, kind) => `
+          SELECT s.slug, ${uri}, (SELECT n FROM ap_feed_rev), '${kind}'
+            FROM posts p JOIN sites s ON s.id = p.site_id`;
+    db.exec([
+      zet('trg_feed_tl_ins', 'INSERT', 'ap_timeline', 'NEW.slug', 'NEW.id', 'new'),
+      zet('trg_feed_tl_upd', 'UPDATE OF content, media_json, nsfw, cw, url, poll_json, quote_json, embed_json', 'ap_timeline', 'NEW.slug', 'NEW.id', 'updated'),
+      zet('trg_feed_tl_del', 'DELETE', 'ap_timeline', 'OLD.slug', 'OLD.id', 'deleted'),
+      zet('trg_feed_mn_ins', 'INSERT', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'new'),
+      zet('trg_feed_mn_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_mentions', 'NEW.slug', 'NEW.object_uri', 'updated'),
+      zet('trg_feed_mn_del', 'DELETE', 'ap_mentions', 'OLD.slug', 'OLD.object_uri', 'deleted'),
+      zet('trg_feed_ob_ins', 'INSERT', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'new'),
+      zet('trg_feed_ob_upd', 'UPDATE OF content, attachments', 'ap_outbox', 'NEW.site_slug', 'NEW.id', 'updated'),
+      zet('trg_feed_ob_del', 'DELETE', 'ap_outbox', 'OLD.site_slug', 'OLD.id', 'deleted'),
+      // ap_interactions draagt geen slug: die hangt aan de POST. Vandaar de join,
+      // en vandaar dat deze drie niet in de gewone vorm passen.
+      zet('trg_feed_ia_ins', 'INSERT', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'new')} WHERE p.id = NEW.post_id`),
+      zet('trg_feed_ia_upd', 'UPDATE OF content, media_json, quote_json, embed_json', 'ap_interactions', '', '', '', `${joinPosts('NEW.object_uri', 'updated')} WHERE p.id = NEW.post_id`),
+      zet('trg_feed_ia_del', 'DELETE', 'ap_interactions', '', '', '', `${joinPosts('OLD.object_uri', 'deleted')} WHERE p.id = OLD.post_id`),
+    ].join('\n'));
+  } catch (e) {
+    // Niet fataal: zonder deze tabel valt het wachten terug op "altijd de tijd
+    // volmaken", en dat is traag maar niet stuk.
+    console.error('❌ feed-state triggers:', e.message);
+  }
 }
 
Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/middleware/render.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -249,4 +249,9 @@
     appVersion: APP_VERSION,
     bodyClass: data.bodyClass || 'on-home',
+    // Welke module(s) deze pagina nodig heeft (shaer-bqr). De shell zet ze op
+    // body[data-js]; de bootstrap daar importeert ze uit /assets/js/mod/.
+    // Alleen kleine letters, cijfers, streepjes en spaties -- de naam wordt een
+    // pad.
+    pageJs: /^[a-z0-9 -]*$/.test(String(data.pageJs || '')) ? (data.pageJs || '') : '',
     socialDescr: data.socialDescr || '',
     socialImage: data.socialImage || '',
@@ -286,6 +291,10 @@
         ? _site.accent : '#e8b04b';
       const _navPalette = (_site && _site.palette) ? _site.palette : 'klonkt';
+      // Welke modules de nieuwe pagina wil (shaer-bqr). De bootstrap in de shell
+      // zet dit op de body en haalt op wat er nieuw bij staat; 'chrome' hoort er
+      // altijd bij, want die komt bij elke navigatie opnieuw binnen.
+      const _navJs = ('chrome ' + (locals.pageJs || '')).trim();
       const triggerJson = JSON.stringify({
-        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette },
+        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs },
         pcmsPostSwap: data.post ? {
           title: data.post.title,
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/activitypub.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -253,5 +253,5 @@
 }
 queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
-queueRoute('follows', (id) => Guardianship.followsCollection(id));
+queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
 // §5.3 turned around (shaer-p729): what this ward has asked to follow, still
 // waiting on its guardians. Owner-only like the rest — who a child wants to
@@ -268,8 +268,40 @@
 // they follow) as Create(Note) items, so an app (Shaer) can build a unified
 // feed. Anyone else gets 403; the inbox stays write-only for the public.
-router.get('/ap/users/:slug/inbox', (req, res) => {
+router.get('/ap/users/:slug/inbox', async (req, res) => {
   const auth = OAuth.verifyBearer(req.headers.authorization);
   if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
   const base = baseUrl(req);
+  // Wachten is een UITBREIDING van deze lezing, geen tweede endpoint (shaer-n05).
+  // Geef `since` (de shaer:cursor van je vorige antwoord) en `wait` mee, en het
+  // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee
+  // gedraagt de route zich exact zoals altijd.
+  //
+  // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan
+  // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van
+  // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede
+  // ronde.
+  const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
+  if (req.query.since && wachtS > 0) {
+    const afbreken = new AbortController();
+    res.on('close', () => afbreken.abort());   // client hing op: niet doorgaan met wachten
+    const uit = await AP.waitForFeedChange(auth.site.slug, {
+      since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
+    });
+    if (res.writableEnded || afbreken.signal.aborted) return undefined;
+    // Niets veranderd? Dan een LEEG antwoord (Barts punt): de hele collectie
+    // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn
+    // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost
+    // nieuws nog steeds maar één rondje -- beter dan een apart seintje-endpoint,
+    // dat voor nieuws twee rondjes nodig heeft.
+    //
+    // De '0'-uitzondering is geen franje. Ontbreekt ap_feed_state (een instance
+    // die de migratie nog niet draaide), dan geeft feedCursor altijd '0' terug,
+    // en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien. Bij
+    // een lege merksteen sturen we dus gewoon de collectie.
+    if (!uit.changed && uit.cursor !== '0') {
+      res.set('Vary', 'Authorization');
+      return res.status(304).end();
+    }
+  }
   // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
   // world outside the fediverse is the guardians' call. The gate is applied
@@ -285,5 +317,10 @@
   const playbackAllowed = embedsAllowed
     && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
-  const posts = AP.getTimeline(auth.site.slug, 60).map((t) => ({
+  const rows = AP.getTimeline(auth.site.slug, 60);
+  // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
+  // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
+  // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
+  const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
+  const posts = rows.map((t) => ({
     id: `${t.id}#create`,
     type: 'Create',
@@ -333,6 +370,6 @@
       // Whether THIS account already liked/boosted the note, so the app's
       // detail-view buttons show the current state (and can toggle/undo).
-      'shaer:liked': !!t.liked,
-      'shaer:boosted': !!t.boosted,
+      'shaer:liked': !!(reacties.get(t.id) || {}).liked,
+      'shaer:boosted': !!(reacties.get(t.id) || {}).boosted,
       // An external (non-fediverse) embed, thumbnail-only and never an iframe.
       // Omitted entirely when the gate is closed (see above).
@@ -439,7 +476,12 @@
       'shaer:externalLinks': playbackAllowed,
     },
+    // Het merk van wat hierin zit. Geef hem terug als `since` om op het
+    // volgende te wachten. NA het samenstellen bepaald, zodat hij precies dekt
+    // wat je in handen hebt en niet iets dat er ondertussen bij kwam.
+    'shaer:cursor': AP.feedCursor(auth.site.slug),
     totalItems: items.length,
     orderedItems: items,
   });
+  return undefined;
 });
 
Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-audio.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -111,4 +111,6 @@
   const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed';
   renderPage(req, res, 'pages/admin-audio', {
+    // admin-audio neemt de track-editor op, dus die module hoort erbij.
+    pageJs: 'admin-audio track-editor',
     pageTitleKey: 'admin.t_audio',
     bodyClass: 'on-admin',
Index: src/routes/admin-epk.js
===================================================================
--- src/routes/admin-epk.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-epk.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -33,4 +33,5 @@
   try { const r = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]'); if (Array.isArray(r)) chosen = r; } catch (e) {}
   renderPage(req, res, 'pages/admin-epk', {
+    pageJs: 'admin-epk',
     pageTitleKey: 'admin.t_epk',
     bodyClass: 'on-admin',
Index: src/routes/admin-media.js
===================================================================
--- src/routes/admin-media.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-media.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -81,4 +81,5 @@
     .sort((a, b) => b._mtime - a._mtime); // newest first
   renderPage(req, res, 'pages/admin-media', {
+    pageJs: 'admin-media',
     pageTitleKey: 'admin.t_media',
     bodyClass: 'on-admin',
@@ -122,4 +123,5 @@
   if (!site) return res.status(404).send('Site required');
   renderPage(req, res, 'pages/admin-videos', {
+    pageJs: 'admin-videos',
     pageTitleKey: 'admin.t_media',
     bodyClass: 'on-admin',
Index: src/routes/admin-paid.js
===================================================================
--- src/routes/admin-paid.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-paid.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -36,4 +36,5 @@
   if (!gate(req, res)) return;
   renderPage(req, res, 'pages/admin-paid', {
+    pageJs: 'admin-paid',
     pageTitleKey: 'apaid.t',
     bodyClass: 'on-admin',
Index: src/routes/admin-playlists.js
===================================================================
--- src/routes/admin-playlists.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-playlists.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -62,4 +62,6 @@
   const playlists = PlaylistService.list(site.id);
   renderPage(req, res, 'pages/admin-playlists', {
+    // admin-playlists neemt de playlist-editor op, dus die module hoort erbij.
+    pageJs: 'admin-playlists playlist-editor',
     pageTitleKey: 'admin.t_playlists',
     playlists,
Index: src/routes/admin-push.js
===================================================================
--- src/routes/admin-push.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-push.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -12,4 +12,5 @@
 router.get('/', requireSiteManager, async (req, res) => {
   renderPage(req, res, 'pages/admin-push', {
+    pageJs: 'admin-push',
     pageTitleKey: 'push.t',
     bodyClass: 'on-admin',
Index: src/routes/admin-settings.js
===================================================================
--- src/routes/admin-settings.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-settings.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -66,4 +66,5 @@
 router.get('/', requireGod, (req, res) => {
   renderPage(req, res, 'pages/admin-settings', {
+    pageJs: 'admin-settings',
     pageTitleKey: 'admin.t_settings',
     bodyClass: 'on-admin',
Index: src/routes/admin-sites.js
===================================================================
--- src/routes/admin-sites.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin-sites.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -200,4 +200,5 @@
 router.get('/new', requireGod, (req, res) => {
   renderPage(req, res, 'pages/admin-site-edit', {
+    pageJs: 'admin-site-edit',
     pageTitleKey: 'admin.t_newsite',
     bodyClass: 'on-admin',
@@ -283,4 +284,5 @@
 
   renderPage(req, res, 'pages/admin-site-edit', {
+    pageJs: 'admin-site-edit',
     pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
     bodyClass: 'on-admin',
Index: src/routes/admin.js
===================================================================
--- src/routes/admin.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/admin.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -87,4 +87,5 @@
 router.get('/handleiding', requireAuth, (req, res) => {
   renderPage(req, res, 'pages/admin-help', {
+    pageJs: 'admin-help',
     pageTitleKey: 'admin.t_manual',
     bodyClass: 'on-admin',
Index: src/routes/auth.js
===================================================================
--- src/routes/auth.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/auth.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -115,4 +115,5 @@
   if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
   renderPage(req, res, 'pages/auth-register', {
+    pageJs: 'auth-register',
     pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
     error: null, username: '', email: '', siteName: '', next,
@@ -124,4 +125,5 @@
   const next = safeNext(req.body.next) || '';
   const renderErr = (error) => renderPage(req, res, 'pages/auth-register', {
+    pageJs: 'auth-register',
     pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
     error, username: username || '', email: email || '', siteName: siteName || '', next,
@@ -205,5 +207,5 @@
       } else {
         // Production without SMTP: NEVER log the token. Refer to the CLI break-glass.
-        console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`);
+        console.log(`[password-reset] requested for ${user.email} (no SMTP — use 'npm run reset-admin')`);
       }
     }
Index: src/routes/download.js
===================================================================
--- src/routes/download.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/download.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -85,4 +85,5 @@
   const fan = req.session && req.session.user;
   renderPage(req, res, 'pages/download', {
+    pageJs: 'download',
     pageTitle: track.title + ' — download',
     bodyClass: 'on-download',
Index: src/routes/guardian.js
===================================================================
--- src/routes/guardian.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/guardian.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -57,5 +57,15 @@
     'prop_line', 'prop_embeds', 'prop_play', 'prop_on', 'prop_off',
     'prop_st_open', 'prop_st_accepted', 'prop_st_rejected', 'prop_st_expired',
-    'panel_guards_far'];
+    'panel_guards_far',
+    // Het gate-paneel per ward (shaer-ahy.1): een rij per gate, met het soort en
+    // de drempel erbij. De namen volgen de catalogus in gated.js.
+    'gate_externalEmbeds', 'gate_externalPlayback', 'gate_follows',
+    'gate_kind_setting', 'gate_kind_perRequest', 'gate_kind_handover',
+    'gate_unknown', 'gate_threshold', 'gate_threshold_unknown',
+    'gate_irreversible', 'gate_waiting', 'gate_blocked', 'gate_propose',
+    // Oppikken en afhandelen van een hulpvraag (shaer-lgo).
+    'help_pick', 'help_close', 'help_picked_by', 'help_handled_by', 'help_handled_note',
+    'help_close_ask', 'help_close_yes', 'help_just_now', 'help_hours', 'help_days',
+    'help_archive', 'help_archive_hide'];
   const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
   s.wave = i18nT(L, 'guardian.wave');
@@ -71,6 +81,14 @@
             emoji_json, actor_emoji_json, media_json, quote_json, embed_json
      FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
-  ).all(site.slug).map((h) => ({
+  ).all(site.slug);
+  // De gedeelde staat in EEN query (shaer-lgo): wie er al op af is en of het is
+  // afgesloten. Per kaart vragen zou hier een N+1 opleveren, en dit is precies
+  // het scherm dat een guardian in een haast openslaat.
+  const helpStaat = Guardianship.help.statusFor(help.map((h) => h.object_uri));
+  const helpItems = help.map((h) => ({
     ...h,
+    // Bij twijfel OPEN. Een hulpvraag die er afgehandeld uitziet terwijl hij dat
+    // niet is, is de gevaarlijke fout -- niet andersom.
+    state: helpStaat.get(h.object_uri) || { open: true, pickedUpBy: [], handled: null, ageMs: null },
     // The dashboard is built in the browser, so it gets the body finished: the
     // same partial de Krant and Berichten use. A 🛟 often carries a screenshot
@@ -103,4 +121,8 @@
         status: Guardianship.gated.sentStatus(p, Date.now()),
       })),
+      // Alles wat voor dit kind gated is op EEN plek, met per gate het soort en
+      // de drempel (shaer-ahy.1). Losse knoppen lieten een guardian zelf
+      // uitzoeken wat er allemaal geldt; wat niet verstelbaar is stond nergens.
+      gates: wardGates(site.slug, w.other_uri),
     })),
     offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
@@ -113,5 +135,5 @@
       id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value,
     })),
-    help,
+    help: helpItems,
     strings: uiStrings(L),
   };
@@ -192,4 +214,5 @@
   const site = siteForUser(req);
   if (!site) return res.status(404).json({ error: 'no_site' });
+  const L = resolveLang(req);
   ensureWardConnections(site);
   const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
@@ -209,4 +232,10 @@
       cw: p.cw || null,
       media: p.media_json ? JSON.parse(p.media_json) : [],
+      // Een post van je ward hoort er hetzelfde uit te zien als in de Krant en
+      // in Berichten: dezelfde partial, dus opmaak, media, quote-kaart en
+      // embed. Tot nu toe kreeg de PWA alleen kale content -- een guardian zag
+      // een lege regel waar een foto stond. `content` blijft ernaast staan voor
+      // een client die nog uit de cache draait.
+      body_html: renderNoteBody(p, L),
     }));
   res.json({ items, following: wardUris.size });
@@ -319,4 +348,42 @@
   if (!r) return res.status(502).json({ error: 'delivery' });
   res.json({ ok: true, delivered: r.delivered });
+});
+
+// ── Een hulpvraag oppikken of afsluiten (shaer-lgo) ───────────────
+// Gaat naar de WARD en naar de MEDE-GUARDIANS. De ward hoort te weten dat er
+// iemand komt -- dat is de helft van de gerustheid -- en de anderen dat het
+// loopt, zodat niemand denkt dat de ander het al doet.
+//
+// OPPIKKEN mag stapelen: twee mensen die tegelijk reageren is geen probleem.
+// AFSLUITEN kent geen terugdraai; leeft de vraag nog, dan wordt hij opnieuw
+// gesteld. De stevige bevestiging zit in de client, net als bij het loslaten van
+// een ward: nooit een window.confirm.
+router.post('/api/help/:kind', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
+  const site = siteForUser(req);
+  if (!site) return res.status(404).json({ error: 'no_site' });
+  const kind = req.params.kind === 'handled' ? 'handled' : 'pickup';
+  const noteUri = String(req.body?.note || '').trim();
+  const wardUri = String(req.body?.ward || '').trim();
+  if (!noteUri || !/^https?:\/\//i.test(noteUri)) return res.status(400).json({ error: 'no_note' });
+  // Alleen over een hulpvraag van een kind dat je echt bewaakt.
+  const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
+  if (!isWard) return res.status(403).json({ error: 'not_your_ward' });
+
+  const me = AP.actorId((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site.slug);
+  // Onze eigen kopie meteen, zonder op bezorging te wachten: het scherm van
+  // degene die klikt hoort niet te liegen omdat een andere server traag is.
+  Guardianship.help.record(noteUri, me, kind, null);
+
+  const anderen = Guardianship.listGuardians(wardUri.replace(/.*\/ap\/users\//, '')) || [];
+  const ontvangers = [wardUri, ...anderen.map((g) => g.other_uri)].filter((u) => u && u !== me);
+  const r = await AP.deliverDirectNote(site, {
+    recipients: ontvangers,
+    text: kind === 'handled' ? 'Deze hulpvraag is afgehandeld.' : 'Ik kijk hiernaar.',
+    helpMark: { kind, noteUri },
+  }).catch(() => null);
+  // Bezorging kan mislukken; de eigen staat staat er dan toch. Dat melden we,
+  // want "verstuurd" zeggen terwijl het niet aankwam is hier het ergste soort
+  // stilte.
+  res.json({ ok: true, delivered: r ? r.delivered : 0, recipients: ontvangers.length });
 });
 
@@ -531,4 +598,29 @@
 /** The playback gate of a ward we host (5.6): the heavier sibling. */
 function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
+/**
+ * De gate-rijen van een ward voor het paneel.
+ *
+ * De standen komen uit onze eigen kolommen als we het kind hosten; bij een ward
+ * elders weten we ze niet en blijft het NULL -- onbekend, niet uit. Het aantal
+ * guardians idem: dat wordt op de server van die ward bijgehouden, en zonder dat
+ * getal wordt er geen drempel verzonnen.
+ */
+function wardGates(mySlug, wardUri) {
+  const statuses = wardGuardianStatuses(wardUri);
+  const wachtend = Guardianship.follows.listReviewsByDirection(mySlug, 'incoming')
+    .filter((r) => r.ward_uri === wardUri).length;
+  return Guardianship.gated.gateRows({
+    settings: {
+      'shaer:externalEmbeds': wardEmbedSetting(wardUri),
+      'shaer:externalPlayback': wardPlaybackSetting(wardUri),
+    },
+    guardianCount: statuses ? statuses.length : null,
+    proposals: Guardianship.gated.listSent(mySlug, wardUri).map((p) => ({
+      feature: p.feature, value: !!p.value, status: Guardianship.gated.sentStatus(p, Date.now()),
+    })),
+    waiting: { 'shaer:follows': wachtend || undefined },
+  });
+}
+
 function wardGateSetting(uri, column) {
   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
Index: src/routes/paid.js
===================================================================
--- src/routes/paid.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/paid.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -76,4 +76,5 @@
   const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
   renderPage(req, res, 'pages/paid-passkey', {
+    pageJs: 'paid-passkey',
     pageTitleKey: 'ppk.t', bodyClass: 'on-post',
     optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/routes/posts.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -256,4 +256,6 @@
 
   renderPage(req, res, 'pages/post-edit', {
+    // post-edit neemt de playlist-editor op.
+    pageJs: 'post-edit playlist-editor',
     post: {
       id: uuid(),
@@ -795,4 +797,5 @@
   }
   renderPage(req, res, 'pages/authorize-interaction', {
+    pageJs: 'authorize-interaction',
     pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
     bodyClass: 'on-special',
@@ -806,5 +809,5 @@
     liked: !!req.query.liked,
     boosted: !!req.query.boosted,
-    reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false },
+    reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
     siteTitle: site ? site.title : '',
   });
@@ -839,9 +842,10 @@
   let on = false;
   if (site && uri) {
-    on = !ActivityPubService.getMyReactions(site.slug, uri).liked;
+    on = !ActivityPubService.getReaction(site.slug, uri).liked;
     ActivityPubService.resolveRemoteNote(uri)
       .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
       .catch((e) => console.warn('[AP] remote like failed:', e.message));
-    ActivityPubService.setMyReaction(site.slug, uri, 'like', on);
+    // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
+    ActivityPubService.setReaction(site.slug, uri, 'like', on);
   }
   if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
@@ -856,5 +860,5 @@
   let on = false;
   if (site && uri) {
-    on = !ActivityPubService.getMyReactions(site.slug, uri).boosted;
+    on = !ActivityPubService.getReaction(site.slug, uri).boosted;
     ActivityPubService.resolveRemoteNote(uri)
       .then((note) => {
@@ -862,10 +866,15 @@
         const id = note.object_uri || uri;
         return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
-          // Boost → store the post in the timeline (even if you don't follow the author) so it
-          // surfaces in the Cirkel; unboost → just clear the flag.
-          .then(() => on ? ActivityPubService.upsertBoostedNote(site.slug, note) : ActivityPubService.unmarkBoosted(site.slug, id));
+          // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
+          // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
+          // de Cirkel verschijnt.
+          .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
       })
       .catch((e) => console.warn('[AP] remote boost failed:', e.message));
-    ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
+    // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via
+    // setReaction en niet via setMyReaction: ook dit korte moment mag geen
+    // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de
+    // note, zodat de post ook in je tijdlijn belandt.
+    ActivityPubService.setReaction(site.slug, uri, 'boost', on);
   }
   if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
@@ -930,5 +939,5 @@
     : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
   renderPage(req, res, 'pages/messages', {
-    pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
+    pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages', items, seenAt,
     hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
     success: req.query.success || null, error: req.query.error || null,
@@ -965,4 +974,52 @@
   } catch { /* fall through */ }
   res.redirect(back + '?error=quickreply');
+});
+
+// Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt
+// bepaalt de draad zelf (zie groupConversations → replyTo):
+//   - hangt de draad aan een post van jou, dan is dit een gewone reply op het
+//     nieuwste ontvangen bericht erin: deliverReply, publiek zoals de thread;
+//   - hangt hij aan een persoon, dan is het een direct bericht terug.
+// Rijk in beide gevallen: `content` is de HTML uit de reply-editor, `text` de
+// platte versie die de editor er altijd bij levert (en die het no-JS-formulier
+// als enige stuurt).
+router.post('/messages/reply', requireSiteManager, async (req, res) => {
+  const site = res.locals.site;
+  const back = `${res.locals.siteUrlBase || ''}/messages`;
+  if (!site) return res.status(404).send('Site required');
+  const text = String(req.body.text || '');
+  const html = String(req.body.content || '');
+  let attachments = [];
+  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
+  let mentions;
+  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
+  const language = String(req.body.language || '');
+  // Leeg is leeg: een bericht zonder tekst EN zonder media is geen bericht.
+  if (!text.trim() && !html.trim() && !attachments.length) return res.redirect(back + '?error=reply_empty');
+
+  const interactionId = parseInt(req.body.interaction_id, 10) || 0;
+  const postSlug = String(req.body.post_slug || '');
+  const toActor = String(req.body.to || '');
+  try {
+    if (interactionId && postSlug) {
+      const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, postSlug);
+      const parent = ActivityPubService.getInteractionById(interactionId);
+      // De parent MOET bij deze post horen: anders zou een gemanipuleerd
+      // formulier een antwoord onder andermans draad kunnen hangen.
+      if (!post || !parent || parent.post_id !== post.id) return res.redirect(back + '?error=reply_target');
+      await ActivityPubService.deliverReply(site, {
+        postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions, language,
+      });
+    } else if (/^https?:\/\//i.test(toActor)) {
+      const r = await Guardianship.deliverDirectNote(site, { recipients: [toActor], text, html, language, attachments });
+      if (!r) return res.redirect(back + '?error=reply_failed');
+    } else {
+      return res.redirect(back + '?error=reply_target');
+    }
+  } catch (e) {
+    console.warn('[AP] reply from Berichten failed:', e.message);
+    return res.redirect(back + '?error=reply_failed');
+  }
+  res.redirect(back + '?success=reply_sent');
 });
 
@@ -1139,4 +1196,5 @@
   }
   renderPage(req, res, 'pages/news', {
+    pageJs: 'news',
     pageTitle: 'News', bodyClass: 'on-special',
     timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
@@ -1229,7 +1287,7 @@
   let on = false;
   if (site && note) {
-    on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
+    on = !ActivityPubService.getReaction(site.slug, note).liked;
     try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
-    if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);
+    ActivityPubService.setReaction(site.slug, note, 'like', on);
   }
   if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
@@ -1243,16 +1301,14 @@
   let on = false;
   if (site && note) {
-    on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
+    on = !ActivityPubService.getReaction(site.slug, note).boosted;
     try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
+    ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
     if (on) {
-      ActivityPubService.markBoosted(site.slug, note); // instant UI state
       // Fire-and-forget: re-resolve the note so the cached row is refreshed
       // (cover/content) — boosting again heals a stale copy from EVERY boost
       // path, not just the interact page.
       ActivityPubService.resolveRemoteNote(note)
-        .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); })
+        .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
         .catch(() => { /* best-effort */ });
-    } else {
-      ActivityPubService.unmarkBoosted(site.slug, note);
     }
   }
@@ -1338,4 +1394,5 @@
     const { newerPost, olderPost } = postNeighbors(site, post);
     return renderPage(req, res, 'pages/paid-gate', {
+    pageJs: 'paid-gate',
       pageTitle: post.title || 'Voor supporters',
       bodyClass: 'on-special',
@@ -1454,4 +1511,5 @@
 
   renderPage(req, res, 'pages/post', {
+    pageJs: 'post',
     post,
     poll: ActivityPubService.ownPollView(post),
@@ -1505,17 +1563,19 @@
   const kind = req.body.kind === 'boost' ? 'boost' : 'like';
   if (parent && parent.post_id === post.id && parent.object_uri) {
-    if (kind === 'boost') {
-      // Toggle: boost an unboosted comment, or retract it (Undo Announce) if already boosted.
-      const on = !parent.acted_boost;
-      ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', parent.object_uri, parent.actor_uri)
-        .catch((e) => console.warn('[AP] reaction failed:', e.message));
-      ActivityPubService.setInteractionBoosted(parent.id, on);
-    } else {
-      // Toggle: like an unliked comment, or un-favourite (Undo Like) if already liked.
-      const on = !parent.acted_like;
-      ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', parent.object_uri, parent.actor_uri)
-        .catch((e) => console.warn('[AP] reaction failed:', e.message));
-      ActivityPubService.setInteractionLiked(parent.id, on);
-    }
+    // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
+    // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
+    // de kolom en de knop uit de tussentabel, dan draait een divergentie de
+    // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
+    const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
+    const on = kind === 'boost' ? !ik.boosted : !ik.liked;
+    ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
+      .catch((e) => console.warn('[AP] reaction failed:', e.message));
+    // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
+    // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
+    // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
+    // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
+    ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
+    if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
+    else ActivityPubService.setInteractionLiked(parent.id, on);
   }
   res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
Index: src/server.js
===================================================================
--- src/server.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/server.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -65,5 +65,5 @@
 import apRoutes from './routes/activitypub.js';
 import oauthRoutes from './routes/oauth.js';
-import { apWants, startDeliveryWorker, selfHealTimeline } from './services/ActivityPubService.js';
+import { apWants, startDeliveryWorker, selfHealTimeline, migrateReactions } from './services/ActivityPubService.js';
 
 // SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one
@@ -197,4 +197,8 @@
 startScheduler(); // release planning: publish scheduled posts when publish_at is reached
 startDeliveryWorker(); // retry failed fediverse deliveries with backoff
+// Once per REACTIONS_MIGRATION_VERSION bump: reacties naar de tussentabel, onder
+// de canonieke object-URI. Moet VOOR het serveren, want vanaf nu leest de code
+// die tabel -- draait hij niet, dan tonen oude likes als niet-gegeven.
+migrateReactions();
 selfHealTimeline(); // once per SELFHEAL_VERSION bump: re-sync the fediverse cache (covers/edits) after a drastic update
 
@@ -542,11 +546,20 @@
   try { d = e.data ? e.data.json() : {}; } catch (err) { /* non-JSON push */ }
   const title = d.title || 'Klonkt';
-  e.waitUntil(self.registration.showNotification(title, {
-    body: d.body || '',
-    icon: '/favicon.svg',
-    badge: '/favicon.svg',
-    tag: d.type ? ('klonkt-' + d.type) : undefined,   // collapse same-type bursts
-    data: { url: d.url || '/' },
-  }));
+  e.waitUntil(Promise.all([
+    self.registration.showNotification(title, {
+      body: d.body || '',
+      icon: '/favicon.svg',
+      badge: '/favicon.svg',
+      tag: d.type ? ('klonkt-' + d.type) : undefined,   // collapse same-type bursts
+      data: { url: d.url || '/' },
+    }),
+    // Wek ook een pagina die al openstaat. De push IS het teken dat er iets
+    // veranderd is, dus een aparte live-verbinding ernaast zou hetzelfde nog
+    // eens doen -- en die tweede zou alleen werken zolang de app open is,
+    // terwijl dit kanaal er ook is als hij dicht is. Een kanaal, twee doelen.
+    self.clients.matchAll({ type: 'window', includeUncontrolled: true })
+      .then(list => { for (const c of list) c.postMessage({ klonkt: 'push', type: d.type || null }); })
+      .catch(() => { /* geen open venster: niets te wekken */ }),
+  ]));
 });
 self.addEventListener('notificationclick', e => {
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/ActivityPubService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -627,4 +627,19 @@
 // outboxId), sorted as one stream. Consecutive likes/boosts on the same post collapse into
 // one grouped item (actors list + count) so activity doesn't drown out conversations.
+/** ap_outbox.attachments ([{url, mediaType, name}]) naar de vorm die note-body
+ *  leest (media_json: [{url, type, name}]). Geeft null bij niets of rommel,
+ *  zodat een kapotte kolom hooguit media kost en niet de hele regel. */
+function outboxMediaJson(attachments) {
+  if (!attachments) return null;
+  try {
+    const list = JSON.parse(attachments);
+    if (!Array.isArray(list) || !list.length) return null;
+    const media = list
+      .filter((a) => a && a.url)
+      .map((a) => ({ url: a.url, type: a.mediaType || a.type || '', name: a.name || undefined }));
+    return media.length ? JSON.stringify(media) : null;
+  } catch { return null; }
+}
+
 export function getMessages(slug, limit, offset) {
   const off = Math.max(0, offset || 0);
@@ -638,9 +653,30 @@
     for (const m of listOutbox(slug).slice(0, need)) {
       items.push({
-        type: 'sent', outboxId: m.id, to_handle: m.to_handle, in_reply_to: m.in_reply_to,
-        content: m.content, editable: m.editable, language: m.language, created_at: m.created_at,
+        type: 'sent', outboxId: m.id, to_handle: m.to_handle, to_actor: m.to_actor, to_actors: m.to_actors,
+        in_reply_to: m.in_reply_to, post_slug: m.post_slug, content: m.content,
+        editable: m.editable, language: m.language, created_at: m.created_at,
+        // Je eigen bericht hoort er hetzelfde uit te zien als dat van een ander:
+        // note-body rendert Berichten, de Krant en de Guardian-PWA, maar leest
+        // media uit media_json met een `type`, terwijl ap_outbox ze als
+        // `attachments` met een `mediaType` bewaart. Zonder deze vertaling kwam
+        // een foto die JIJ meestuurde als kale tekst binnen.
+        media_json: outboxMediaJson(m.attachments),
       });
     }
   } catch { /* ignore */ }
+  // Een verzonden antwoord kent zijn post_slug maar niet de titel (ap_outbox
+  // bewaart die niet). Zonder titel toont een draad waarin JIJ als enige iets
+  // zei alleen een slug, dus vullen we ze in één query aan.
+  try {
+    const missing = [...new Set(items.filter((i) => i.post_slug && !i.post_title).map((i) => i.post_slug))];
+    if (missing.length) {
+      const rows = db.prepare(
+        `SELECT slug, title FROM posts WHERE slug IN (${missing.map(() => '?').join(',')})
+           AND site_id = (SELECT id FROM sites WHERE slug = ?)`,
+      ).all(...missing, slug);
+      const byslug = new Map(rows.map((r) => [r.slug, r.title]));
+      for (const i of items) if (i.post_slug && !i.post_title) i.post_title = byslug.get(i.post_slug) || null;
+    }
+  } catch { /* zonder titel valt de draad terug op de slug */ }
   items.sort((a, b) => _msgTs(b) - _msgTs(a)); // NaN-safe (zie getNotifications)
   const out = [];
@@ -656,5 +692,112 @@
     out.push(it);
   }
-  return out.slice(off, off + lim);
+  // Antwoorden, mentions en je eigen verzonden berichten vouwen samen tot
+  // draden; likes/boosts/follows/reports blijven losse regels. Na deze stap
+  // telt een draad als één item voor de paginering, wat klopt: je scrolt door
+  // gesprekken, niet door losse zinnen.
+  return groupConversations(out).slice(off, off + lim);
+}
+
+// De drie soorten die samen een gesprek vormen. Vroeger zaten ze in drie
+// aparte chips: 'reply' en 'mention' onder Berichten/Gesprekken (afhankelijk van
+// de zichtbaarheid) en 'sent' onder Verzonden. Wie een uitwisseling wilde volgen
+// moest dus tussen chips heen en weer, terwijl het één draad is.
+const CONV_TYPES = new Set(['reply', 'mention', 'sent']);
+
+/** Waar hangt dit bericht aan? Twee soorten draden, en de volgorde telt:
+ *
+ *  1. Aan een post van jou. Een ontvangen antwoord kent zijn post via de join
+ *     op `posts`, een verzonden antwoord via ap_outbox.post_slug. Dat is
+ *     dezelfde sleutel, en daarom staan ze nu in dezelfde draad.
+ *  2. Aan een persoon. Een mention hangt aan niets van jou (het is iemands
+ *     eigen post waarin je genoemd wordt) en heeft geen post_slug; die draad
+ *     loopt per tegenpartij.
+ *
+ *  De post wint van de persoon: twee mensen die onder dezelfde post reageren
+ *  voeren één gesprek, geen twee. Geeft null terug voor alles wat geen gesprek
+ *  is (likes, boosts, follows, reports, poll-uitslagen); die stromen ongemoeid
+ *  door.
+ */
+export function threadKey(it) {
+  if (!it || !CONV_TYPES.has(it.type)) return null;
+  if (it.post_slug) return `post:${it.post_slug}`;
+  let who = it.handle || it.to_handle || '';
+  // Een direct bericht kan zonder to_handle in de tabel staan (de handle van de
+  // ontvanger was niet af te leiden). De eerste uit to_actors is dan alsnog de
+  // tegenpartij, en zonder deze terugval kreeg een gesprek dat JIJ begon geen
+  // draad -- precies het geval waarin het meest onlogisch is dat het los blijft.
+  if (!who && it.to_actors) {
+    try {
+      const first = JSON.parse(it.to_actors)[0];
+      if (first) who = deriveHandle(first);
+    } catch { /* geen bruikbare lijst → geen sleutel, het blijft een losse regel */ }
+  }
+  const norm = String(who || '').trim().toLowerCase().replace(/^@/, '');
+  return norm ? `actor:${norm}` : null;
+}
+
+/** Vouw losse berichten samen tot draden, met alles wat geen gesprek is
+ *  ongemoeid ertussen. Verwacht [items] al gesorteerd op created_at aflopend
+ *  (zoals getMessages ze aanlevert); de draad komt daardoor op de plek van zijn
+ *  nieuwste bericht te staan en `created_at` van de draad IS dat bericht. Binnen
+ *  de draad draait het om: een gesprek leest naar beneden, oud naar nieuw.
+ */
+export function groupConversations(items) {
+  const threads = new Map();
+  const out = [];
+  for (const it of items || []) {
+    const key = threadKey(it);
+    if (!key) { out.push(it); continue; }
+    let t = threads.get(key);
+    if (!t) {
+      // Eerste keer dat we deze draad zien = het nieuwste bericht erin, want de
+      // invoer is aflopend gesorteerd. Vandaar created_at hier en niet later.
+      t = { type: 'thread', key, post: null, people: [], messages: [], created_at: it.created_at };
+      threads.set(key, t);
+      out.push(t);
+    }
+    t.messages.push(it);
+    // De context bij de draad: gaat het over een post, dan hoort de link
+    // erbij, anders is een los antwoord in een lijst niet te plaatsen.
+    // De titel blijft LEEG zolang hij onbekend is, in plaats van terug te
+    // vallen op de slug: het nieuwste bericht in een draad is vaak je eigen
+    // verzonden antwoord, en dat kent alleen de slug. Zou die de titel worden,
+    // dan kan het ontvangen antwoord eronder de echte titel niet meer
+    // invullen. De terugval op de slug hoort in de weergave, niet in de data.
+    if (it.post_slug) {
+      if (!t.post) t.post = { slug: it.post_slug, title: it.post_title || null };
+      else if (!t.post.title && it.post_title) t.post.title = it.post_title;
+    }
+  }
+  for (const t of threads.values()) {
+    t.messages.sort((a, b) => _msgTs(a) - _msgTs(b));
+    t.count = t.messages.length;
+    // Wie zit er in dit gesprek, jij niet meegerekend: 'sent' ben jij.
+    const seen = new Set();
+    for (const m of t.messages) {
+      if (m.type === 'sent') continue;
+      const h = m.handle || m.name;
+      if (!h || seen.has(h)) continue;
+      seen.add(h);
+      t.people.push({ name: m.name, handle: m.handle, icon: m.icon, url: m.url });
+    }
+    // Heb JIJ in deze draad iets gezegd? Bepaalt of hij als uitwisseling of als
+    // onbeantwoord bericht leest.
+    t.mine = t.messages.some((m) => m.type === 'sent');
+    // Waar gaat een antwoord uit deze draad heen? Twee paden, en ze sluiten
+    // elkaar uit: hangt de draad aan een post, dan antwoord je op het NIEUWSTE
+    // ontvangen bericht erin (dat is de parent van de thread) -- anders is het
+    // een direct bericht aan de tegenpartij.
+    const inkomend = t.messages.filter((m) => m.type !== 'sent');
+    const laatste = inkomend[inkomend.length - 1];
+    t.replyTo = {
+      interactionId: (laatste && laatste.interactionId) || null,
+      postSlug: (t.post && t.post.slug) || null,
+      actorUri: (laatste && (laatste.actorUri || laatste.url))
+        || (t.messages.find((m) => m.to_actor) || {}).to_actor
+        || (() => { try { return JSON.parse((t.messages.find((m) => m.to_actors) || {}).to_actors || '[]')[0] || null; } catch { return null; } })(),
+    };
+  }
+  return out;
 }
 
@@ -1043,7 +1186,20 @@
   const siteIcon = (site && site.profile_photo) || null;
 
+  // Wat JIJ met deze reacties deed komt uit de tussentabel, niet meer uit
+  // acted_* (shaer-ipb). Eén batch-lookup, want een drukke thread zou anders een
+  // N+1 worden. De sleutel loopt door canonicalReactionUri, precies zoals aan de
+  // schrijfkant -- staat dezelfde note toevallig ook in je tijdlijn, dan is het
+  // één feit en niet twee knoppen die los van elkaar aan kunnen staan.
+  const mijnSleutel = new Map();
+  for (const r of rows) {
+    if (r.kind === 'reply' && r.object_uri) mijnSleutel.set(r.object_uri, canonicalReactionUri(site && site.slug, r.object_uri));
+  }
+  const mijn = getReactionsFor(site && site.slug, [...mijnSleutel.values()]);
+  const mijnReactie = (uri) => mijn.get(mijnSleutel.get(uri)) || { liked: false, boosted: false };
+
   const nodes = [];
   for (const r of rows) {
     if (r.kind !== 'reply') continue;
+    const ik = mijnReactie(r.object_uri);
     nodes.push({
       noteId: r.object_uri, parent: r.parent_uri || null, mine: false, id: r.id,
@@ -1052,5 +1208,5 @@
       actor_icon: r.actor_icon, content: stripLeadingMentions(r.content), created_at: r.published || r.created_at,
       emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (thread render)
-      acted_boost: !!r.acted_boost, acted_like: !!r.acted_like,
+      acted_boost: ik.boosted, acted_like: ik.liked,
       children: [],
     });
@@ -1141,12 +1297,39 @@
 }
 
-export async function fetchActor(url) {
+export async function fetchActor(url, opts = {}) {
+  // Authorized fetch (Mastodons secure mode): zo'n instance serveert zijn
+  // actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
+  // verzoek en antwoordt anders met 401. Zonder sleutel kunnen we een correct
+  // ondertekende Follow van die instance niet verifiëren en wijzen we hem af,
+  // waarna Mastodon het dagenlang blijft proberen. Gemeten op boiert.eu: vier
+  // accounts eindeloos geweigerd, en precies die vier geven 401 op een
+  // onbetekende GET (shaer-afq).
+  //
+  // Geen kip-ei: om ONZE handtekening te controleren haalt de andere kant ons
+  // actor-document op, en dat serveert Klonkt publiek.
+  //
+  // ONBETEKEND EERST, en dat is een veiligheidskeuze en geen optimalisatie.
+  // verifyRequest haalt de keyId-URL op VOORDAT er iets geverifieerd is, en die
+  // URL komt uit een header die iedereen mag sturen. Tekenden we dat verzoek
+  // standaard, dan kan een volslagen onbekende ons een ONDERTEKEND verzoek laten
+  // sturen naar een adres van zijn keuze -- met onze identiteit eronder. Dat is
+  // precies hoe een instance op een blocklist belandt. Ondertekenen doen we dus
+  // pas als het onbetekend niet lukt, en dan alleen voor deze ene URL.
+  let doc = null;
   try {
     const r = await safeFetch(url, { headers: { Accept: 'application/activity+json' } });
-    if (!r.ok) return null;
-    const len = Number(r.headers.get('content-length') || 0);
-    if (len > 2_000_000) return null; // refuse oversized actor docs
-    return await r.json();
-  } catch { return null; }
+    if (r.ok) {
+      const len = Number(r.headers.get('content-length') || 0);
+      if (len > 2_000_000) return null; // refuse oversized actor docs
+      doc = await r.json();
+    }
+  } catch { /* val door naar de ondertekende poging */ }
+  // Genoeg? Dan klaar. Sommige instances serveren onbetekend wel een document
+  // maar zonder sleutel; voor een verificatie hebben we daar niets aan, dus die
+  // telt als mislukt.
+  if (doc && (!opts.asSlug || (doc.publicKey && doc.publicKey.publicKeyPem))) return doc;
+  if (!opts.asSlug) return doc;
+  const signed = await signedGetJson(opts.asSlug, url).catch(() => null);
+  return (signed && signed.id) ? signed : doc;
 }
 
@@ -1225,4 +1408,14 @@
 }
 
+/** Een lokale site om GETs mee te ondertekenen wanneer er geen specifieke is
+ *  (de gedeelde inbox). Gecached: dit draait per binnenkomend verzoek. */
+let _signSlug;
+function anySigningSlug() {
+  if (_signSlug !== undefined) return _signSlug;
+  try { const r = db.prepare('SELECT slug FROM sites ORDER BY rowid LIMIT 1').get(); _signSlug = (r && r.slug) || null; }
+  catch { _signSlug = null; }
+  return _signSlug;
+}
+
 // Best-effort verification of an incoming signed request. Returns the sender's
 // actor doc if the signature checks out, else null. (Not gating yet — MVP.)
@@ -1230,10 +1423,14 @@
 // federating servers with drifting clocks; an operator can widen it via env.
 const SIG_MAX_SKEW_MS = (Number(process.env.AP_SIG_MAX_SKEW_MIN) || 60) * 60 * 1000;
-export async function verifyRequest(req) {
+export async function verifyRequest(req, asSlug = null) {
   const sigH = req.headers['signature'];
   if (!sigH) return null;
   const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
   if (!p.keyId || !p.signature) return null;
-  const actor = await fetchActor(p.keyId.split('#')[0]);
+  // Onderteken de sleutel-ophaal, anders faalt elke instance met authorized
+  // fetch (shaer-afq). Zonder aangewezen site -- de gedeelde inbox -- tekenen we
+  // als een willekeurige lokale actor: elke Klonkt-actor is een geldige
+  // ondertekenaar, het gaat de andere kant er alleen om DAT er ondertekend is.
+  const actor = await fetchActor(p.keyId.split('#')[0], { asSlug: asSlug || anySigningSlug() });
   const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
   if (!pem) return null;
@@ -1495,4 +1692,157 @@
 }
 
+/**
+ * Een DOORGESTUURDE activiteit alsnog verifiëren (shaer-s8k).
+ *
+ * Reageert iemand in een thread, dan stuurt de server van de oorspronkelijke
+ * poster die reactie door naar de deelnemers -- en ondertekent met zijn EIGEN
+ * sleutel. De handtekening klopt dan, maar de ondertekenaar is niet de auteur,
+ * dus de gate hieronder wees hem af. Gevolg: reacties van derden kwamen niet
+ * binnen, zonder dat iemand een fout zag.
+ *
+ * Mastodon lost dit op met een LD-Signature over de payload. Dat vraagt
+ * JSON-LD-canonicalisatie; wij doen het lichter en strenger: we geloven de
+ * bezorgde inhoud NIET en halen het object op bij de bron.
+ *
+ * Vier voorwaarden, en geen ervan is optioneel:
+ *
+ *  1. Alleen Create en Update. Een doorgestuurde Delete is per definitie niet te
+ *     dereferencen -- het object is weg -- dus die blijft geweigerd.
+ *  2. De host van de object-id MOET die van de geclaimde actor zijn. Zonder dit
+ *     anker wijst een doorsturer je naar een host die hij zelf beheert, waar
+ *     attributedTo alles kan beweren.
+ *  3. Het OPGEHAALDE object wordt gebruikt, niet de bezorgde payload. Anders
+ *     levert een doorsturer een echt id met verdraaide inhoud.
+ *  4. Mislukt het ophalen, of wijst het object zichzelf niet toe aan de
+ *     geclaimde actor, dan blijft het een weigering. Geen twijfelgeval opslaan.
+ */
+/** Kennen we deze note? Een eigen post, een eigen outbox-antwoord, een
+ *  gecachete post in de tijdlijn, of een reactie die al in een thread van ons
+ *  staat. Alle vier zijn een geldige reden dat iemand ons een antwoord daarop
+ *  doorstuurt; iets anders is dat niet. */
+function knownNoteUri(uri) {
+  if (!uri || typeof uri !== 'string') return false;
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  try {
+    if (base && uri.startsWith(`${base}/ap/notes/`)) {
+      const seg = decodeURIComponent(uri.slice(`${base}/ap/notes/`.length).split(/[?#]/)[0]);
+      if (db.prepare('SELECT 1 FROM ap_outbox WHERE id = ?').get(seg)) return true;
+      if (db.prepare('SELECT 1 FROM posts WHERE id = ?').get(seg)) return true;
+    }
+    if (db.prepare('SELECT 1 FROM ap_timeline WHERE id = ? LIMIT 1').get(uri)) return true;
+    if (db.prepare('SELECT 1 FROM ap_interactions WHERE object_uri = ? LIMIT 1').get(uri)) return true;
+    // Een antwoord dat we al bezorgd kregen van iemand die we volgen (shaer-e9g).
+    if (db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ? LIMIT 1').get(uri)) return true;
+  } catch { /* bij twijfel niet ophalen */ }
+  return false;
+}
+
+/**
+ * Onthoud dat we dit bericht al eens bezorgd kregen.
+ *
+ * Alleen de URI. Geen inhoud, niets op het scherm, geen tweede weergave -- dit
+ * beantwoordt uitsluitend de vraag "kennen wij dit bericht?" die knownNoteUri
+ * stelt voordat er iets bij de bron wordt opgehaald.
+ *
+ * De beller bepaalt WIE er onthouden wordt, en dat is de hele veiligheidsvraag:
+ * onthouden we zomaar alles wat iemand aflevert, dan kan een vreemde eerst een
+ * bericht neerleggen en daarna met een doorgestuurd antwoord dáárop ons naar een
+ * adres van zijn keuze sturen. Vandaar dat handleInbox dit alleen doet voor
+ * schrijvers die je zelf volgt.
+ */
+const SEEN_NOTES_DAYS = 30;
+let _seenSinceSnoei = 0;
+function rememberNoteUri(uri) {
+  if (!uri || typeof uri !== 'string') return;
+  try {
+    db.prepare('INSERT OR IGNORE INTO ap_seen_notes (uri) VALUES (?)').run(uri);
+    // Af en toe opruimen, niet bij het opstarten: een server die weken doorloopt
+    // zou anders nooit snoeien. Doorsturen gebeurt kort na het antwoord, dus wat
+    // ouder is dan een maand beantwoordt geen enkele vraag meer.
+    if (++_seenSinceSnoei >= 500) {
+      _seenSinceSnoei = 0;
+      const r = db.prepare(`DELETE FROM ap_seen_notes WHERE created_at < datetime('now', '-${SEEN_NOTES_DAYS} days')`).run();
+      if (r.changes) console.log(`[AP] seen notes: ${r.changes} pruned`);
+    }
+  } catch { /* niet fataal */ }
+}
+const isFollowedActor = (uri) => {
+  try { return !!db.prepare('SELECT 1 FROM ap_following WHERE actor_uri = ? LIMIT 1').get(uri); } catch { return false; }
+};
+
+// Mislukte dereferences kort onthouden. Mastodon herhaalt een bezorging
+// dagenlang; zonder dit doet elke herhaling de fetch opnieuw, ook als die de
+// vorige twintig keer niets opleverde. Dempt meteen de scherpte van misbruik.
+const _derefMiss = new Map();
+const DEREF_MISS_MS = 30 * 60 * 1000;
+function derefRecentlyFailed(uri) {
+  const t = _derefMiss.get(uri);
+  if (t === undefined) return false;
+  if (Date.now() - t > DEREF_MISS_MS) { _derefMiss.delete(uri); return false; }
+  return true;
+}
+function noteDerefFailure(uri) {
+  if (_derefMiss.size > 500) {   // simpele begrenzing: oudste helft eruit
+    const oud = [..._derefMiss.entries()].sort((a, b) => a[1] - b[1]).slice(0, 250);
+    for (const [k] of oud) _derefMiss.delete(k);
+  }
+  _derefMiss.set(uri, Date.now());
+}
+
+async function dereferenceForwarded(act, claimedActor, type, slugParam) {
+  // Every exit states its reason. Five of the six used to return silently, so a
+  // rejection count could not be told apart from a narrowing that closed too far
+  // — and that is exactly the measurement shaer-drf is waiting for. Bounded by
+  // the signer-mismatch rate (tens per hour), so this is not a noisy log.
+  const skipped = (reason, detail) => {
+    console.log(`[AP] inbox forwarded, skipped (${reason}):`, claimedActor, detail || '');
+    return null;
+  };
+  if (type !== 'Create' && type !== 'Update') return skipped('not Create/Update', type);
+  const o = act && act.object;
+  const objId = typeof o === 'string' ? o : (o && o.id);
+  if (!objId || typeof objId !== 'string' || !/^https:\/\//i.test(objId)) return skipped('no https object id', objId || '(none)');
+  try {
+    if (new URL(objId).host !== new URL(claimedActor).host) return skipped('host anchor', objId);   // ankereis
+  } catch { return skipped('unparsable id', objId); }
+  // Alleen dereferencen als het object beweert een antwoord te zijn op iets van
+  // ONS (shaer-drf). Zonder die eis zijn claimedActor en object.id allebei door
+  // de aanvaller gekozen en eist het host-anker alleen dat ze aan elkaar gelijk
+  // zijn -- dan kan iedereen met een werkende actor ons naar elke URL sturen.
+  // Doorsturen bestaat juist omdát wij in de thread zitten, dus deze eis kost
+  // niets aan legitiem verkeer waarvan we de ouder kennen.
+  const parent = typeof o === 'object' && o
+    ? (typeof o.inReplyTo === 'string' ? o.inReplyTo : (o.inReplyTo && o.inReplyTo.id))
+    : null;
+  if (!knownNoteUri(parent)) return skipped('unknown inReplyTo', parent || '(none)');
+  if (derefRecentlyFailed(objId)) return skipped('recent failure', objId);
+  // Onbetekend eerst; tekenen alleen als terugval. Anders kan een ander ons een
+  // ONDERTEKEND verzoek naar een adres van zijn keuze laten sturen -- dezelfde
+  // reden als bij fetchActor sinds efe5633.
+  let fetched = await apGetJson(objId).catch(() => null);
+  if (!fetched || fetched.id !== objId) {
+    // The signer used to be slugParam, which is null on the shared inbox — and
+    // that is where forwarded traffic lands, because we advertise a sharedInbox.
+    // signedGetJson falls back to an unsigned GET for a null slug, so a source in
+    // secure mode could never be dereferenced at all. Same fix verifyRequest got
+    // in shaer-afq: any local actor is a valid signer.
+    const asSlug = slugParam || anySigningSlug();
+    if (asSlug) fetched = await signedGetJson(asSlug, objId).catch(() => null);
+  }
+  const attributed = fetched && (typeof fetched.attributedTo === 'string'
+    ? fetched.attributedTo
+    : (fetched.attributedTo && fetched.attributedTo.id));
+  if (!fetched || fetched.id !== objId) {
+    noteDerefFailure(objId);
+    return skipped('fetch failed', objId);
+  }
+  if (attributed !== claimedActor) {
+    // Not a transport hiccup: the source itself says someone else wrote this.
+    noteDerefFailure(objId);
+    return skipped('attributedTo mismatch', `${objId} claims ${attributed || '(none)'}`);
+  }
+  return fetched;
+}
+
 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
 export async function handleInbox(req, slugParam, preVerified = null) {
@@ -1508,5 +1858,5 @@
   // keeps everything below identical, including the actor-versus-signer check,
   // which is exactly the check that must not be skipped for being local.
-  const verified = preVerified || await verifyRequest(req).catch(() => null);
+  const verified = preVerified || await verifyRequest(req, slugParam).catch(() => null);
 
   // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very
@@ -1518,6 +1868,24 @@
   const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer', 'Move'];
   if (GATED.includes(type)) {
-    if (!verified || !claimedActor || verified.id !== claimedActor) {
-      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
+    // Een geldige handtekening van iemand anders dan de auteur is doorsturen,
+    // geen vervalsing. Haal het object dan bij de bron op in plaats van het af
+    // te wijzen; lukt dat niet, dan valt het door naar de weigering hieronder.
+    let forwarded = null;
+    if (verified && claimedActor && verified.id !== claimedActor) {
+      forwarded = await dereferenceForwarded(act, claimedActor, type, slugParam).catch(() => null);
+      if (forwarded) {
+        act.object = forwarded;   // de OPGEHAALDE inhoud, niet de bezorgde
+        console.log('[AP] inbox forwarded, verified at the source:', type, claimedActor, 'via', verified.id);
+      }
+    }
+    if (!forwarded && (!verified || !claimedActor || verified.id !== claimedActor)) {
+      // Drie verschillende oorzaken, die eerder allemaal "unsigned/invalid"
+      // heetten: geen handtekening meegestuurd, wel een handtekening maar niet
+      // te verifiëren (meestal een opgeheven account waarvan de sleutel weg is),
+      // of geldig ondertekend door iemand anders.
+      const reden = verified ? '(signer mismatch)'
+        : (req.headers && req.headers.signature) ? '(signature present, unverifiable)'
+        : '(no signature)';
+      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, reden);
       return 401;
     }
@@ -1809,8 +2177,31 @@
       }
     }
+    // Een ANTWOORD van iemand die we volgen: bewaar de URI (shaer-e9g). Zo'n
+    // bericht komt hier gewoon binnen, ondertekend door de schrijver zelf, maar
+    // belongsInTimeline houdt het uit de Krant en daarna raakten we het kwijt.
+    // Kwam er later een doorgestuurd antwoord OP dat bericht, dan kenden we de
+    // ouder niet en wezen we het af -- terwijl we hem wel degelijk hadden gehad.
+    // Er verandert niets aan wat we tonen of van vreemden aannemen: de schrijver
+    // moet iemand zijn die je zelf bent gaan volgen.
+    if (actorUri && !isLocalActor && o.id && o.inReplyTo && noteVisibility(o) !== 'direct' && isFollowedActor(actorUri)) {
+      rememberNoteUri(o.id);
+    }
     // Mentioned in a post that is NOT a reply to our content (a reply to us already returned
     // above): store a mention notification for each of our actors named in the Mention tags.
     // Requires our own base prefix on the tag href — /ap/users/<slug> on a REMOTE host is
     // someone else's actor, not ours.
+    // Een markering op een hulpvraag (shaer-lgo): een mede-guardian laat weten
+    // dat hij ernaar kijkt, of dat het is afgehandeld. Gewone directe note met
+    // een shaer:-markering, net als de zwaai -- dus die komt hier langs. VOOR de
+    // mention-opslag, want dit is staat en geen bericht om te bewaren; de ward
+    // krijgt hem wel als bericht te lezen, en dat gebeurt hieronder.
+    if (actorUri && !isLocalActor) {
+      const mark = Guardianship.help.parseMarker(o);
+      if (mark) {
+        const ai = actorInfo(await resolveActor(actorUri).catch(() => null), actorUri);
+        Guardianship.help.record(mark.noteUri, actorUri, mark.kind, ai && ai.handle);
+        console.log('[AP] help', mark.kind, actorUri, '→', mark.noteUri);
+      }
+    }
     if (actorUri && !isLocalActor && o.id) {
       const slugs = localMentionSlugs(o.tag, base);
@@ -2497,6 +2888,17 @@
         const kind = type === 'Announce' ? 'boost' : 'like';
         await sendInteraction(site, kind, objUri, authorUri);
-        setMyReaction(site.slug, targetUri, kind, true);
-        if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } }
+        // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
+        // De note gaat mee zodat een boost de post je tijdlijn in trekt.
+        try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
+        catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
+        // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
+        // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
+        // de reactie wel opgeslagen (setMyReaction, de webroute leest die),
+        // maar kreeg de app altijd liked:false terug: het hartje sprong bij de
+        // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood
+        // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like.
+        // Anders dan bij een boost geen upsert: een like hoort een post niet
+        // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
+        // terecht een no-op.
         return { status: 202, url: objUri };
       }
@@ -2547,6 +2949,6 @@
           const objUri = (note && note.object_uri) || innerTarget;
           await sendInteraction(site, kind, objUri, note && note.actor_uri);
-          setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false);
-          if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } }
+          try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
+          catch { /* non-fatal */ }
           return { status: 202, url: objUri };
         }
@@ -2913,5 +3315,9 @@
 }
 export function listOutbox(siteSlug) {
-  return db.prepare('SELECT id, content, to_handle, in_reply_to, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
+  // post_slug reist mee sinds Berichten gesprekken toont: het is de sleutel
+  // waarop een verzonden antwoord bij de ontvangen antwoorden op dezelfde post
+  // gaat staan (zie threadKey). Zonder die kolom viel een uitwisseling uit
+  // elkaar in "Verzonden" en "Gesprekken".
+  return db.prepare('SELECT id, content, to_handle, to_actor, to_actors, post_slug, in_reply_to, attachments, language, created_at FROM ap_outbox WHERE site_slug = ? ORDER BY created_at DESC')
     .all(siteSlug).map((r) => { const c = stripLeadingMentions(r.content); return { ...r, content: c, editable: outboxEditableText(c) }; });
 }
@@ -3051,5 +3457,27 @@
   return { ins: _insTl, list: _listTl, del: _delTl };
 }
-export function getTimeline(slug, limit, offset) { return tlStmts().list.all(slug, limit || 50, offset || 0); }
+/**
+ * De tijdlijn, met liked/boosted uit de TUSSENTABEL (shaer-9e9).
+ *
+ * De rijen komen met SELECT *, dus ap_timeline.liked en .boosted liften mee --
+ * en die zijn sinds fase 1 nog maar een afgeleide. De Krant tekende zijn
+ * knoppen daar wel op, terwijl de toggle al uit getReaction besliste: tekenen en
+ * beslissen leunden dus op verschillende bronnen. Ze waren het eens zolang de
+ * migratie ze gelijk hield, maar dat was synchronisatie en geen ontwerp.
+ *
+ * Bewust in JS en niet als join: met SELECT * zouden twee kolommen `liked`
+ * heten en hangt het van de driver af welke wint. Eén extra query per pagina
+ * (dezelfde batch die de C2S-tijdlijn gebruikt) is dat niet waard.
+ */
+export function getTimeline(slug, limit, offset) {
+  const rows = tlStmts().list.all(slug, limit || 50, offset || 0);
+  const reacties = getReactionsFor(slug, rows.map((r) => r.id));
+  for (const r of rows) {
+    const x = reacties.get(r.id);
+    r.liked = !!(x && x.liked);
+    r.boosted = !!(x && x.boosted);
+  }
+  return rows;
+}
 
 /**
@@ -3087,4 +3515,86 @@
 }
 
+/**
+ * Een merk voor "is er iets veranderd aan wat de inbox-lezing zou opleveren?"
+ * (shaer-n05).
+ *
+ * Alle VIER de poten die de inbox samenvoegt tellen mee -- tijdlijn, berichten,
+ * antwoorden op je eigen posts, en wat je zelf verstuurde. Zou er een ontbreken,
+ * dan blijft een wachtende client slapen terwijl er wel degelijk iets is
+ * bijgekomen, en dat is erger dan niet wachten: het lijkt te werken.
+ *
+ * rowid en niet een tijdstempel: rowid loopt strikt op per invoeging, terwijl
+ * twee dingen in dezelfde seconde kunnen aankomen en een `published` van een
+ * andere server niet te vertrouwen is.
+ *
+ * Ondoorzichtig voor de client. Hij krijgt hem terug en geeft hem ongewijzigd
+ * mee; de vorm mag veranderen zonder dat dat iets breekt.
+ */
+export function feedCursor(slug) {
+  try {
+    const r = db.prepare('SELECT MAX(rev) AS n FROM ap_feed_state WHERE slug = ?').get(slug);
+    return String((r && r.n) || 0);
+  } catch { return '0'; }
+}
+
+/**
+ * Wat er sinds `rev` met deze tijdlijn gebeurd is: welke berichten er nieuw zijn,
+ * bewerkt, of weg.
+ *
+ * Nog niet gebruikt door een leespad -- de vorm van de aankomst is shaer-of7 en
+ * de "bewerkt"-markering is daar nog een open beslissing. Maar de gegevens
+ * ontstaan hoe dan ook bij het bijhouden van de merksteen, en dit is de enige
+ * plek waar ze samen te lezen zijn.
+ */
+export function feedChangesSince(slug, rev, limit = 200) {
+  try {
+    return db.prepare(`SELECT object_uri, kind, rev FROM ap_feed_state
+                        WHERE slug = ? AND rev > ? ORDER BY rev ASC LIMIT ?`)
+      .all(slug, parseInt(rev, 10) || 0, limit);
+  } catch { return []; }
+}
+
+// Zoveel clients mogen er tegelijk op EEN account staan wachten. Een client met
+// een kapotte herverbind-lus mag de instance niet vastzetten; de overtolligen
+// krijgen gewoon meteen antwoord in plaats van een fout.
+const FEED_WAIT_MAX = 4;
+const _wachters = new Map();
+
+/**
+ * Wacht tot de inbox-lezing iets anders zou opleveren dan bij `since`.
+ *
+ * Bewust met een interne tik en niet met een gebeurtenis-emitter. Een emitter
+ * moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die je
+ * vergeet is precies de melding die nooit aankomt. Twee tot vier MAX(rowid)-
+ * queries per seconde is niets, en dit kan niets missen. Prijs: hooguit een tik
+ * vertraging.
+ */
+export async function waitForFeedChange(slug, opts = {}) {
+  const tickMs = Math.max(50, opts.tickMs || 1000);
+  const waitMs = Math.max(0, opts.waitMs || 0);
+  const since = String(opts.since || '');
+  let cursor = feedCursor(slug);
+  // Geen sinds, al iets veranderd, of niet willen wachten: meteen antwoorden.
+  if (!since || since !== cursor || !waitMs) return { cursor, changed: !!since && since !== cursor, waited: false };
+
+  const bezet = _wachters.get(slug) || 0;
+  if (bezet >= FEED_WAIT_MAX) return { cursor, changed: false, waited: false, busy: true };
+  _wachters.set(slug, bezet + 1);
+  try {
+    const einde = Date.now() + waitMs;
+    while (Date.now() < einde) {
+      if (opts.signal && opts.signal.aborted) break;   // client hing op
+      const rest = Math.min(tickMs, einde - Date.now());
+      await new Promise((r) => setTimeout(r, rest));
+      cursor = feedCursor(slug);
+      if (cursor !== since) return { cursor, changed: true, waited: true };
+    }
+    return { cursor, changed: false, waited: true };
+  } finally {
+    const n = (_wachters.get(slug) || 1) - 1;
+    if (n > 0) _wachters.set(slug, n); else _wachters.delete(slug);
+  }
+}
+
 export function getDirectMessages(slug, limit) {
   try {
@@ -3243,8 +3753,13 @@
     if (!_cirkelPosts) _cirkelPosts = db.prepare(`
       SELECT t.id, t.author_uri, t.author_name, t.author_handle, t.author_icon, t.author_url,
-             t.content, t.url, t.published, t.media_json, t.boosted, t.nsfw, t.cw
+             t.content, t.url, t.published, t.media_json, t.nsfw, t.cw,
+             (rb.target_uri IS NOT NULL) AS boosted
       FROM ap_timeline t
       LEFT JOIN ap_following f ON f.slug = t.slug AND f.actor_uri = t.author_uri
-      WHERE t.slug = ? AND (f.auto_boost = 1 OR t.boosted = 1)
+      -- Uit de tussentabel, niet uit t.boosted: die kolom is een afgeleide. De
+      -- UNIQUE(site_slug, target_uri, kind) garandeert hoogstens één match, dus
+      -- deze join kan geen rijen verdubbelen.
+      LEFT JOIN ap_my_reactions rb ON rb.site_slug = t.slug AND rb.target_uri = t.id AND rb.kind = 'boost'
+      WHERE t.slug = ? AND (f.auto_boost = 1 OR rb.target_uri IS NOT NULL)
       ORDER BY COALESCE(t.published, t.created_at) DESC, t.rowid DESC
       LIMIT ? OFFSET ?`);
@@ -3255,5 +3770,14 @@
   try { if (!_cirkelMembers) _cirkelMembers = db.prepare('SELECT name, url, icon FROM ap_following WHERE slug = ? AND auto_boost = 1 ORDER BY name'); return _cirkelMembers.all(slug); } catch { return []; }
 }
-// Mark a timeline post as boosted so it shows in the Cirkel (mixed by date).
+// AFGELEIDE, GEEN BRON (shaer-9e9). De waarheid over "heb ik hierop gereageerd"
+// staat in ap_my_reactions; deze vlaggen worden daaruit bijgehouden door
+// setReaction en door niets anders. Roep ze niet los aan -- dan schrijf je de
+// helft, en dat is precies hoe shaer:liked maandenlang false bleef (04aca12).
+//
+// ap_timeline.boosted verdient zijn bestaan wel: hij staat in de WHERE van de
+// Cirkel-feed (getCirkelPosts) en in boostedCount, dus hij is een index en geen
+// kopie. ap_timeline.liked wordt nergens als verzameling bevraagd en kan weg
+// zodra fase 2 lang genoeg goed staat; hij is nu nog het vangnet waarmee
+// terugdraaien een code-revert blijft in plaats van dataherstel.
 let _markBoost, _unmarkBoost, _boostedCount;
 export function markBoosted(slug, noteId) {
@@ -3270,4 +3794,207 @@
   try { if (!_unmarkLike) _unmarkLike = db.prepare('UPDATE ap_timeline SET liked = 0 WHERE slug = ? AND id = ?'); _unmarkLike.run(slug, noteId); } catch { /* ignore */ }
 }
+/**
+ * Zet een reactie van JOU op een object. Dit hoort het enige schrijfpad te zijn
+ * (shaer-9e9): de tussentabel ap_my_reactions is de waarheid, de vlaggen op
+ * ap_timeline zijn de afgeleide. Zolang markLiked en broers los aanroepbaar
+ * blijven kan een aanroeper ze vergeten, en dat is niet hypothetisch -- precies
+ * dat leverde de shaer:liked-bug op (04aca12).
+ *
+ * `opts.note` is de opgeloste remote note bij een boost. Die is niet optioneel
+ * uit netheid: een boost moet de post je tijdlijn IN trekken als je de auteur
+ * niet volgt, anders heeft de vlag geen rij om op te landen en verschijnt de
+ * boost nergens -- ook niet in de Cirkel.
+ *
+ * `opts.flagUri` bestaat omdat de twee bronnen vandaag verschillend gesleuteld
+ * worden: de tussentabel op de URI die de client stuurde, de vlag op de
+ * opgeloste object-URI. Meestal zijn die gelijk, maar niet gegarandeerd. Deze
+ * naad houdt fase 1 gedragsbehoudend; het samentrekken van die twee sleutels is
+ * werk voor fase 2, mét datamigratie.
+ */
+// Reactie-migratie (shaer-9e9). Draait bij boot, EEN keer per bump, net als
+// selfHealTimeline. Bewust automatisch: klonkt-update tilt een hele vloot in een
+// stap naar nieuwe code, en een handmatig script per instance wordt vergeten --
+// terwijl het falen stil is (een reactie die niemand meer ziet geeft geen fout).
+// v2 haalt de derde bron erbij: ap_interactions.acted_* (shaer-ipb). Een bump
+// laat alle stappen opnieuw lopen, en dat mag -- ze zijn alle drie idempotent.
+const REACTIONS_MIGRATION_VERSION = 2;
+
+/**
+ * Brengt alle reacties naar de tussentabel, onder de canonieke object-URI.
+ *
+ * Twee stappen, en ze zijn allebei nodig:
+ *
+ *  1. HERSLEUTELEN. De oude interact-route bewaarde de URI waarmee je binnenkwam
+ *     en de bookmarklet geeft window.location.href door, dus de permalink. Sinds
+ *     canonicalReactionUri wordt er op de object-URI gezocht, waardoor die rijen
+ *     wees zouden zijn. De created_at reist mee: bij hersleutelen weten we
+ *     wanneer je reageerde, bij aanvullen niet.
+ *  2. AANVULLEN vanuit de afgeleide kolommen. Alles wat op oude code via de
+ *     Krant is gegeven staat alleen daar; zonder deze stap toont het als
+ *     niet-gereageerd en klikt een gebruiker opnieuw -- met een tweede Like de
+ *     fediverse in als gevolg.
+ *
+ * Idempotent. Geeft terug wat er gebeurd is, zodat het script het kan tonen.
+ */
+export function migrateReactions(opts = {}) {
+  const uit = { hersleuteld: 0, aangevuld: 0, reacties: 0, overgeslagen: false };
+  try {
+    if (!opts.force) {
+      const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('reactions_migration_version');
+      const cur = r ? (parseInt(r.value, 10) || 0) : 0;
+      if (cur >= REACTIONS_MIGRATION_VERSION) { uit.overgeslagen = true; return uit; }
+    }
+  } catch { return uit; }   // geen app_settings → deze database is te oud om aan te raken
+
+  // Een rij die NIET op een tijdlijn-id staat maar wel op een tijdlijn-url.
+  const wees = `
+    FROM ap_my_reactions r JOIN ap_timeline t ON t.slug = r.site_slug AND t.url = r.target_uri
+     WHERE NOT EXISTS (SELECT 1 FROM ap_timeline t2 WHERE t2.slug = r.site_slug AND t2.id = r.target_uri)`;
+  const scheef = (kind, kolom) => `
+    FROM ap_timeline t
+     WHERE t.${kolom} = 1
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = t.slug AND r.target_uri = t.id AND r.kind = '${kind}')`;
+  // 3. De derde bron: wat JIJ deed met een reactie onder je eigen post. De slug
+  //    hangt hier niet aan de rij maar aan de post; vandaar de twee joins. Een
+  //    rij zonder object_uri kan nooit een reactie dragen (fedi-react eist hem),
+  //    dus die uitsluiting verliest per constructie niets.
+  const acted = (kind, kolom) => `
+    FROM ap_interactions i
+     JOIN posts p ON p.id = i.post_id
+     JOIN sites s ON s.id = p.site_id
+     WHERE i.${kolom} = 1 AND IFNULL(i.object_uri, '') <> ''
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = s.slug AND r.target_uri = i.object_uri AND r.kind = '${kind}')`;
+
+  if (opts.dryRun) {
+    const tel = (sql) => { try { return db.prepare(`SELECT COUNT(*) AS n ${sql}`).get().n; } catch { return 0; } };
+    uit.hersleuteld = tel(wees);
+    uit.aangevuld = tel(scheef('like', 'liked')) + tel(scheef('boost', 'boosted'));
+    uit.reacties = tel(acted('like', 'acted_like')) + tel(acted('boost', 'acted_boost'));
+    return uit;
+  }
+
+  try {
+    db.transaction(() => {
+      // 1. Hersleutelen: eerst de canonieke variant erbij, dan de permalink weg.
+      //    In die volgorde, zodat een onderbreking hooguit een dubbele rij
+      //    oplevert en nooit een verdwenen reactie.
+      uit.hersleuteld = db.prepare(`
+        INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at)
+        SELECT r.site_slug, t.id, r.kind, r.created_at ${wees}`).run().changes;
+      db.prepare(`DELETE FROM ap_my_reactions WHERE rowid IN (SELECT r.rowid ${wees})`).run();
+
+      // 2. Aanvullen vanuit de kolommen.
+      for (const [kind, kolom] of [['like', 'liked'], ['boost', 'boosted']]) {
+        uit.aangevuld += db.prepare(`
+          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
+          SELECT t.slug, t.id, '${kind}' ${scheef(kind, kolom)}`).run().changes;
+      }
+
+      // 3. En vanuit acted_* op de reacties onder je eigen posts.
+      for (const [kind, kolom] of [['like', 'acted_like'], ['boost', 'acted_boost']]) {
+        uit.reacties += db.prepare(`
+          INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind)
+          SELECT s.slug, i.object_uri, '${kind}' ${acted(kind, kolom)}`).run().changes;
+      }
+    })();
+    if (uit.hersleuteld || uit.aangevuld || uit.reacties) {
+      console.log(`[AP] reaction migration v${REACTIONS_MIGRATION_VERSION}: ${uit.hersleuteld} re-keyed, ${uit.aangevuld} backfilled, ${uit.reacties} from comments`);
+    }
+    if (!opts.force) {
+      db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
+        .run('reactions_migration_version', String(REACTIONS_MIGRATION_VERSION));
+    }
+  } catch (e) {
+    // Niet fataal: de kolommen staan er nog, dus de oude waarheid is niet weg.
+    // Een volgende boot probeert het opnieuw, want de versie is niet gezet.
+    console.warn('[AP] reaction migration failed:', e.message);
+  }
+  return uit;
+}
+
+/**
+ * Van wat de client stuurde naar de canonieke sleutel voor een reactie.
+ *
+ * Een post heeft twee URI's: zijn AP-object-id (.../ap/notes/<uuid>) en zijn
+ * leesbare permalink (.../effortlesseffect). De Krant en het C2S-pad spreken de
+ * eerste, de interact-pagina de tweede. Werden reacties onder allebei opgeslagen,
+ * dan bestond dezelfde like twee keer -- en erger: een like uit de Krant was op
+ * de interact-pagina onzichtbaar, want daar werd op de permalink gezocht.
+ *
+ * Dit was de naad die fase 1 bewust open liet ("samentrekken is werk voor fase
+ * 2"). Robin liep er meteen tegenaan: een geboost en geliket bericht toonde geen
+ * highlight. Vandaar hier, en niet later.
+ *
+ * De object-URI wint, want dat is waar ap_timeline op sleutelt en waar de
+ * backfill op is gebaseerd. Kennen we de post niet, dan blijft de invoer staan:
+ * een reactie op iets buiten je tijdlijn moet gewoon werken.
+ */
+export function canonicalReactionUri(slug, uri) {
+  if (!slug || !uri) return uri;
+  try {
+    if (db.prepare('SELECT 1 FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, uri)) return uri;
+    const row = db.prepare('SELECT id FROM ap_timeline WHERE slug = ? AND url = ? LIMIT 1').get(slug, uri);
+    return (row && row.id) || uri;
+  } catch { return uri; }
+}
+
+/**
+ * Wat heb IK met dit object gedaan? Leest de tussentabel, de bron van waarheid
+ * sinds shaer-9e9 fase 2. Vervangt getMyReactions en getTimelineReaction, die
+ * dezelfde vraag beantwoordden uit twee verschillende bronnen.
+ */
+export function getReaction(slug, uri) {
+  try {
+    const key = canonicalReactionUri(slug, uri);
+    const rows = (slug && key)
+      ? db.prepare('SELECT kind FROM ap_my_reactions WHERE site_slug = ? AND target_uri = ?').all(slug, key)
+      : [];
+    return { liked: rows.some((r) => r.kind === 'like'), boosted: rows.some((r) => r.kind === 'boost') };
+  } catch { return { liked: false, boosted: false }; }
+}
+
+/**
+ * Dezelfde vraag voor een hele pagina in EEN query. De C2S-tijdlijn zet
+ * shaer:liked op elke post; per rij vragen zou dat een N+1 maken, en dan had je
+ * een consistentiebug geruild voor een traagheidsbug.
+ */
+export function getReactionsFor(slug, uris) {
+  const out = new Map();
+  const list = [...new Set((uris || []).filter(Boolean))].slice(0, 500);
+  if (!slug || !list.length) return out;
+  try {
+    const rows = db.prepare(
+      `SELECT target_uri, kind FROM ap_my_reactions
+        WHERE site_slug = ? AND target_uri IN (${list.map(() => '?').join(',')})`,
+    ).all(slug, ...list);
+    for (const r of rows) {
+      const cur = out.get(r.target_uri) || { liked: false, boosted: false };
+      if (r.kind === 'like') cur.liked = true;
+      if (r.kind === 'boost') cur.boosted = true;
+      out.set(r.target_uri, cur);
+    }
+  } catch { /* leeg = niets gereageerd, en dat is een veilige uitkomst */ }
+  return out;
+}
+
+export function setReaction(slug, uri, kind, on, opts = {}) {
+  if (!slug || !uri || (kind !== 'like' && kind !== 'boost')) return;
+  // EEN sleutel voor beide bronnen. opts.flagUri is de opgeloste object-URI van
+  // de aanroeper (het C2S-pad kent die uit resolveRemoteNote en dat is
+  // betrouwbaarder dan onze cache); anders leiden we hem af. Vroeger kreeg de
+  // tussentabel de URI die de client stuurde en de vlag de opgeloste -- dat
+  // maakte dezelfde like onvindbaar vanaf de andere pagina.
+  const flagUri = opts.flagUri || canonicalReactionUri(slug, uri);
+  setMyReaction(slug, flagUri, kind, !!on);
+  if (kind === 'boost') {
+    if (!on) unmarkBoosted(slug, flagUri);
+    else if (opts.note) upsertBoostedNote(slug, opts.note);
+    else markBoosted(slug, flagUri);
+  } else if (on) markLiked(slug, flagUri);
+  else unmarkLiked(slug, flagUri);
+}
+
 export function getTimelineReaction(slug, noteId) {
   try { const r = db.prepare('SELECT liked, boosted FROM ap_timeline WHERE slug = ? AND id = ?').get(slug, noteId); return { liked: !!(r && r.liked), boosted: !!(r && r.boosted) }; } catch { return { liked: false, boosted: false }; }
@@ -3303,5 +4030,12 @@
 }
 export function boostedCount(slug) {
-  try { if (!_boostedCount) _boostedCount = db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE slug = ? AND boosted = 1'); return _boostedCount.get(slug).n; } catch { return 0; }
+  // Geboost EN in je tijdlijn, zoals voorheen: de tussentabel kan ook een boost
+  // bevatten van iets dat er (nog) niet in staat.
+  try {
+    if (!_boostedCount) _boostedCount = db.prepare(`SELECT COUNT(*) AS n FROM ap_my_reactions r
+      JOIN ap_timeline t ON t.slug = r.site_slug AND t.id = r.target_uri
+      WHERE r.site_slug = ? AND r.kind = 'boost'`);
+    return _boostedCount.get(slug).n;
+  } catch { return 0; }
 }
 
@@ -4018,5 +4752,5 @@
     await send(site.slug, inbox, move, `${me}#main-key`, keys.private_pem);
   }
-  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'naar', inboxes.length, 'inbox(en)');
+  console.log('[AP] MOVE announced:', site.slug, '→', target.id, 'to', inboxes.length, 'inbox(es)');
   return { ok: true, target: target.id, inboxes: inboxes.length };
 }
@@ -4198,5 +4932,18 @@
       const wardDoc = await fetchActor(wardUri).catch(() => null);
       const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
-      Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
+      // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande
+      // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde
+      // hier als "deze ward wil deze ward volgen" met het doel weggegooid.
+      // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het
+      // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden.
+      const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri;
+      const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null;
+      const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null;
+      Guardianship.follows.recordReview(gslug, {
+        id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox,
+        follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo),
+        direction: uitgaand ? 'outgoing' : 'incoming',
+        target: doel || null, targetHandle: dai ? dai.handle : null,
+      });
       const L = pushLang(gslug);
       pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fai.name || fai.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian` });
@@ -4301,5 +5048,5 @@
   try {
     const rows = db.prepare(`
-      SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
+      SELECT i.id AS interaction_id, i.kind, i.actor_uri, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.published, i.visibility,
              i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
              p.slug AS post_slug, p.title AS post_title
@@ -4310,4 +5057,7 @@
     for (const r of rows) out.push({
       type: r.kind, name: r.actor_name, handle: r.actor_handle, url: r.actor_url, icon: r.actor_icon,
+      // Waar een antwoord uit de draad heen moet: het id is de parent voor
+      // deliverReply, de uri het adres voor een direct bericht.
+      interactionId: r.interaction_id, actorUri: r.actor_uri,
       content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
       // When the post was written, for display. created_at (when it reached us)
@@ -4543,4 +5293,8 @@
   getOutboxRow: (id) => iStmts().getO.get(id),
   buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
+  // Rijke directe berichten: dezelfde sanitizer als deliverReply gebruikt, zodat
+  // een antwoord uit Berichten door precies één poort gaat.
+  sanitizeHtml: (h) => HtmlSanitizerService.sanitize(h),
+  htmlToPlainText: (h) => HtmlSanitizerService.toPlainText(h),
 });
 /**
@@ -4579,21 +5333,61 @@
   // Guardian PWA / Berichten push. The kid answers an incoming offer in its
   // own Berichten; an existing guardian and a commit land in the PWA.
+  //
+  // De labels hangen aan dezelfde sleutels als het Guardian-paneel, zodat een
+  // melding en het scherm waar hij heen wijst hetzelfde woord gebruiken.
   onEvent: (slug, ev) => {
-    const L = pushLang(slug);
-    const texts = {
-      offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
-      offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
-      committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
-      // §3.2: a guardian ended the relation. The ward hears that someone who
-      // was looking after them has gone; a co-guardian hears they are one fewer.
-      guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
-      coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
-    }[ev.kind];
-    if (!texts) return;
-    const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
-    const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
-    pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url });
+    const p = guardianEventPush(slug, ev);
+    if (p) pushEvent(slug, p);
   },
 });
+
+/**
+ * Welke melding hoort bij een guardianship-gebeurtenis, of geen.
+ *
+ * Apart en puur, omdat dit een BESLISSING is en geen bezorging: de
+ * guardianship-module zendt veertien soorten uit en deze tabel bepaalt welke
+ * daarvan een mens wakker maken. Dat hoort toetsbaar te zijn zonder web-push
+ * erbij te halen.
+ */
+export function guardianEventPush(slug, ev) {
+  const L = pushLang(slug);
+  const texts = {
+    offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
+    offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
+    committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
+    // §3.2: a guardian ended the relation. The ward hears that someone who
+    // was looking after them has gone; a co-guardian hears they are one fewer.
+    guardian_left: ['push.n_guard_left_t', 'push.n_guard_left_b'],
+    coguardian_left: ['push.n_guard_cogleft_t', 'push.n_guard_cogleft_b'],
+    // 5.6 gated settings. Zonder deze twee is de hele tally stil: een guardian
+    // hoort niet dat er een antwoord van hem gewenst is, en dus loopt het
+    // venster leeg en verloopt het voorstel. Een drempel die niemand ziet is
+    // geen drempel.
+    gated_review: ['push.n_gate_ask_t', 'push.n_gate_ask_b'],      // jij moet antwoorden
+    gated_outcome: ['push.n_gate_done_t', 'push.n_gate_done_b'],   // er is besloten
+  }[ev.kind];
+  if (!texts) return null;
+  const who = deriveHandle(ev.candidate || ev.guardian || ev.ward || '') || '?';
+  // Een gate-melding zonder te zeggen WELKE instelling is nutteloos: er zijn er
+  // meer dan een, en ze betekenen heel verschillende dingen voor een kind.
+  const wat = i18nT(L, GATE_LABEL[ev.feature] || 'guardian.prop_embeds');
+  const stand = i18nT(L, ev.value ? 'guardian.prop_on' : 'guardian.prop_off');
+  const uitkomst = i18nT(L, GATE_OUTCOME[ev.outcome] || 'guardian.prop_st_open');
+  const url = (ev.kind === 'offer_received' || ev.kind === 'guardian_left') ? `${pushPrefix(slug)}/messages` : '/guardian';
+  return { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who, wat, stand, uitkomst }), url };
+}
+
+// Van een gated feature naar het woord dat het Guardian-paneel er al voor
+// gebruikt. Een onbekende feature valt terug op het algemene woord in plaats van
+// de melding te laten vervallen: liever een iets vager bericht dan geen bericht.
+const GATE_LABEL = {
+  'shaer:externalEmbeds': 'guardian.prop_embeds',
+  'shaer:externalPlayback': 'guardian.prop_play',
+};
+const GATE_OUTCOME = {
+  accepted: 'guardian.prop_st_accepted',
+  rejected: 'guardian.prop_st_rejected',
+  expired: 'guardian.prop_st_expired',
+};
 
 // The notification duty of FEP-633c 3.6.2, wired once for every place a
@@ -4625,5 +5419,6 @@
   buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
   followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
-  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, setMyReaction, getMyReactions, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
+  feedCursor, feedChangesSince, waitForFeedChange,
+  getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
   webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, getDirectMessages, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
@@ -4631,5 +5426,5 @@
   gateOutgoingFollow, performApprovedFollow,
   parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
-  autoBoostCount, boostedCount, markBoosted, unmarkBoosted, markLiked, unmarkLiked, getTimelineReaction, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
+  autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
   getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
   deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
Index: src/services/ArchiveExportService.js
===================================================================
--- src/services/ArchiveExportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/ArchiveExportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,386 @@
+/**
+ * Export van een draagbaar inhoudsarchief (shaer-1a6).
+ *
+ * Bouwt precies wat docs/EXPORT-FORMAT.md beschrijft. Lees dat eerst; hier staat
+ * alleen wat de code doet, niet waarom het formaat zo is.
+ *
+ * Twee dingen zijn geen implementatiedetail maar eis:
+ *
+ *   REPRODUCEERBAAR  Twee exports van ongewijzigde inhoud horen byte-voor-byte
+ *                    gelijk te zijn, anders is een diff of een checksum nutteloos.
+ *                    Vandaar gesorteerde sleutels, vaste volgorde, geen tijdstip
+ *                    in de postbestanden en een vaste mtime in de zip.
+ *   GEEN CREDENTIALS Dit is niet de storage-zip uit shaer-190t. Hier komt geen
+ *                    sleutel, sessie, hash of DM van een ander in.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import crypto from 'crypto';
+import db from '../config/database.js';
+import { MEDIA_ROOT } from '../config/paths.js';
+
+export const FORMAT_VERSION = 1;
+
+/** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
+export function stableJson(value) {
+  const sorteer = (v) => {
+    if (Array.isArray(v)) return v.map(sorteer);
+    if (v && typeof v === 'object') {
+      const uit = {};
+      for (const k of Object.keys(v).sort()) if (v[k] !== undefined) uit[k] = sorteer(v[k]);
+      return uit;
+    }
+    return v;
+  };
+  return `${JSON.stringify(sorteer(value), null, 2)}\n`;
+}
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+const toISO = (d) => { const t = Date.parse(d); return isNaN(t) ? null : new Date(t).toISOString(); };
+
+const MIME_BY_EXT = {
+  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
+  webp: 'image/webp', avif: 'image/avif', svg: 'image/svg+xml',
+  mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
+  mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg', wav: 'audio/wav', flac: 'audio/flac',
+};
+const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
+const mimeOf = (u) => MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
+const as2TypeOf = (mime) => (mime.startsWith('video/') ? 'Video' : mime.startsWith('audio/') ? 'Audio' : 'Image');
+
+/**
+ * Van een media-URL naar een bestand op schijf.
+ *
+ * /media is een kale express.static op MEDIA_ROOT, dus het URL-pad IS het pad
+ * onder die map. Een absolute URL naar onze eigen origin telt net zo goed als
+ * een pad -- de content slaat allebei op.
+ *
+ * De ../-controle is geen formaliteit: een verzonnen pad in oude inhoud zou
+ * anders een willekeurig bestand van de schijf het archief in trekken.
+ */
+function localMediaPath(url, origin) {
+  let p = String(url || '');
+  if (!p) return null;
+  if (/^https?:/i.test(p)) {
+    try {
+      const u = new URL(p);
+      if (`${u.protocol}//${u.host}` !== origin) return null;   // andermans host: nooit van onze schijf
+      p = u.pathname;
+    } catch { return null; }
+  }
+  if (!p.startsWith('/media/')) return null;
+  const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(p.slice('/media/'.length)));
+  const root = path.resolve(MEDIA_ROOT);
+  if (abs !== root && !abs.startsWith(`${root}${path.sep}`)) return null;
+  return abs;
+}
+
+/** Alle media waar een post naar wijst, in vaste volgorde en zonder dubbelen. */
+function mediaRefsOf(post, origin) {
+  const uit = [];
+  const zie = new Set();
+  const voegToe = (url, name, rol, extra = {}) => {
+    const u = String(url || '').trim();
+    if (!u || zie.has(u)) return;
+    zie.add(u);
+    uit.push({ url: u, name: name || null, rol, ...extra });
+  };
+  // De ROL is niet decoratief. Zonder rol staat er in het archief wel een
+  // bestand, maar niet dat het de cover was of bij de speler hoorde -- en dan
+  // komt de post na een herstel zonder cover en zonder speler terug. Gevonden
+  // door bij de oefenherstel ALLE kolommen te vergelijken in plaats van een
+  // handjevol.
+  voegToe(post.cover_image_url, post.cover_alt, 'cover');
+  voegToe(post.cover_video_url, post.cover_alt, 'coverVideo');
+  for (const m of String(post.content || '').matchAll(/<img[^>]+src=["']([^"']+)["'][^>]*>/gi)) voegToe(m[1], null, 'inline');
+  try {
+    for (const a of JSON.parse(post.c2s_attachments || '[]')) {
+      voegToe(a && a.url, a && a.name, 'c2s');
+      // Een audio-bijlage draagt een poster (de omslag die de speler toont). Die
+      // staat in een eigen veld en zou anders stil wegvallen -- op beta viel dat
+      // pas op bij de export van echte data.
+      voegToe(a && a.poster, a && a.name ? `${a.name} (poster)` : null, 'poster', { posterFor: a && a.url });
+    }
+  } catch { /* kapotte kolom blokkeert de export niet */ }
+  // Gehoste audio: [[track:id]] verwijst naar een audio_tracks-rij met een media-rij eronder.
+  for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
+    try {
+      const t = db.prepare('SELECT t.title, m.storage_path FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.id = ?').get(m[1]);
+      if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title, 'track');
+    } catch { /* geen audio-tabellen: niets te doen */ }
+  }
+  return uit;
+}
+
+/**
+ * De audio-metadata die alleen in de database staat en nergens anders uit te
+ * halen is: titel, artiest, credit, licentie, externe links.
+ *
+ * `shaer:media` koppelt de track aan zijn bestand in het archief. Zonder die
+ * verwijzing weet een importer wel dát er een track was en hoe hij heette, maar
+ * niet wélk van de bijlagen erbij hoort -- en dan valt [[track:]] bij een
+ * herstel op niets terug.
+ */
+function audioOf(post, attachments) {
+  const uit = [];
+  for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
+    try {
+      const t = db.prepare('SELECT t.*, md.storage_path FROM audio_tracks t LEFT JOIN media md ON md.id = t.media_id WHERE t.id = ?').get(m[1]);
+      if (!t) continue;
+      let bestand;
+      if (t.storage_path) {
+        const rel = `/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`;
+        const bij = attachments.find((a) => String(a['shaer:originalUrl'] || '').endsWith(rel));
+        bestand = bij ? bij.url : undefined;
+      }
+      uit.push({
+        'shaer:ref': `[[track:${t.id}]]`,
+        'shaer:media': bestand,
+        name: t.title, artist: t.artist || undefined, album: t.album || undefined,
+        duration: t.duration || undefined, credit: t.credit || undefined, license: t.license || undefined,
+        url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
+      });
+    } catch { /* idem */ }
+  }
+  return uit.length ? uit : undefined;
+}
+
+/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
+function postObject(post, site, origin, attachments) {
+  const heeftTitel = !!(post.title && String(post.title).trim());
+  const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
+  const updated = toISO(post.updated_at);
+  const poll = (() => {
+    try {
+      const d = JSON.parse(post.poll_json || 'null');
+      if (!d || !Array.isArray(d.options) || d.options.length < 2) return null;
+      const opties = d.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) }));
+      return { multiple: !!d.multiple, opties, endTime: d.endTime || null, closed: !!d.closed };
+    } catch { return null; }
+  })();
+  const tags = [];
+  try {
+    for (const t of String(post.tags || '').split(',').map((x) => x.trim()).filter(Boolean)) {
+      tags.push({ type: 'Hashtag', name: t.startsWith('#') ? t : `#${t}`, href: `${origin}/tag/${encodeURIComponent(t.replace(/^#/, ''))}` });
+    }
+  } catch { /* tags zijn optioneel */ }
+
+  return {
+    '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
+    id: `${origin}/ap/notes/${encodeURIComponent(post.id)}`,
+    type: poll ? 'Question' : (heeftTitel ? 'Article' : 'Note'),
+    attributedTo: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
+    name: heeftTitel ? post.title : undefined,
+    content: post.content || '',
+    contentMap: post.language ? { [post.language]: post.content || '' } : undefined,
+    summary: post.content_warning || undefined,
+    sensitive: post.nsfw ? true : undefined,
+    published,
+    updated: (updated && updated !== published) ? updated : undefined,
+    url: `${origin}/${encodeURIComponent(post.slug)}`,
+    attachment: attachments.length ? attachments : undefined,
+    tag: tags.length ? tags : undefined,
+    ...(poll ? (poll.multiple ? { anyOf: poll.opties } : { oneOf: poll.opties }) : {}),
+    endTime: poll ? (poll.endTime || undefined) : undefined,
+    // AS2 kent `closed` op een Question. Zonder dit staat een poll die vroegtijdig
+    // is gesloten na een herstel weer open -- gevonden op echte beta-data.
+    closed: (poll && poll.closed) ? true : undefined,
+    quoteUrl: post.quote_uri || undefined,
+    'shaer:quoteActor': post.quote_actor || undefined,
+    'shaer:slug': post.slug,
+    'shaer:status': post.status || 'draft',
+    'shaer:excerpt': post.excerpt || undefined,
+    'shaer:type': post.type || undefined,
+    'shaer:pinned': post.pinned ? true : undefined,
+    'shaer:noindex': post.noindex ? true : undefined,
+    'shaer:fanOnly': post.fan_only ? true : undefined,
+    'shaer:paid': post.paid ? true : undefined,
+    'shaer:paidMinCents': post.paid ? (post.paid_min_cents || undefined) : undefined,
+    'shaer:apVisibility': post.ap_visibility || undefined,
+    'shaer:publishAt': toISO(post.publish_at) || undefined,
+    'shaer:coverAlt': post.cover_alt || undefined,
+    'shaer:viewCount': post.view_count || undefined,
+    'shaer:audio': audioOf(post, attachments),
+  };
+}
+
+/** De leesbare kopie. Afgeleid, eenrichtingsverkeer -- de importer kijkt hier nooit naar. */
+function readableMarkdown(post, obj) {
+  const fm = [
+    '---',
+    `title: ${JSON.stringify(post.title || post.slug)}`,
+    `slug: ${JSON.stringify(post.slug)}`,
+    `date: ${obj.published || ''}`,
+    `status: ${post.status || 'draft'}`,
+    post.content_warning ? `content_warning: ${JSON.stringify(post.content_warning)}` : null,
+    '---',
+  ].filter((l) => l !== null).join('\n');
+  return `${fm}\n${post.content || ''}\n`;
+}
+
+/**
+ * Bouw het archief als een lijst bestanden: pad -> inhoud (Buffer).
+ *
+ * Bewust geen schrijven naar schijf hier: dat maakt de vorm testbaar zonder
+ * tijdelijke mappen, en de beller bepaalt of het een map of een zip wordt.
+ */
+export function buildArchive(slug, opts = {}) {
+  const origin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
+  if (!site) throw new Error(`onbekende site: ${slug}`);
+
+  const bestanden = new Map();       // pad -> Buffer
+  const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };
+  const ontbrekend = [];             // voor de rapportage van de beller
+
+  // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
+  // is de volgorde van twee posts op dezelfde seconde niet bepaald.
+  const posts = db.prepare(`SELECT * FROM posts WHERE site_id = ?
+                             ORDER BY COALESCE(published_at, created_at) ASC, id ASC`).all(site.id);
+
+  for (const post of posts) {
+    const attachments = [];
+    for (const ref of mediaRefsOf(post, origin)) {
+      const schijf = localMediaPath(ref.url, origin);
+      const mime = mimeOf(ref.url);
+      let bytes = null;
+      if (schijf) { try { bytes = fs.readFileSync(schijf); } catch { bytes = null; } }
+      if (bytes) {
+        const hash = sha256(bytes);
+        const naam = `media/${hash}${extOf(ref.url) ? `.${extOf(ref.url)}` : ''}`;
+        if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
+          url: naam, 'shaer:availability': 'included',
+          'shaer:originalUrl': /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`,
+          'shaer:sha256': hash,
+          'shaer:role': ref.rol,
+          'shaer:posterFor': ref.posterFor || undefined,
+        });
+      } else {
+        // De derde staat uit het formaat: we weten DAT het bestond en waar het
+        // stond, maar we hebben de bytes niet. Stil weglaten zou een leugen zijn.
+        tellingen.mediaMissing += 1;
+        const orig = /^https?:/i.test(ref.url) ? ref.url : `${origin}${ref.url}`;
+        ontbrekend.push({ post: post.slug, url: orig });
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: ref.name || undefined,
+          url: orig, 'shaer:availability': 'missing', 'shaer:originalUrl': orig,
+          'shaer:role': ref.rol,
+          'shaer:posterFor': ref.posterFor || undefined,
+        });
+      }
+    }
+
+    const obj = postObject(post, site, origin, attachments);
+    bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
+    bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
+    tellingen.posts += 1;
+
+    // Antwoorden van anderen: alleen-lezen archief, nooit opnieuw bezorgd.
+    let replies = [];
+    try {
+      replies = db.prepare(`SELECT * FROM ap_interactions WHERE post_id = ? AND kind = 'reply'
+                             ORDER BY COALESCE(published, created_at) ASC, id ASC`).all(post.id);
+    } catch { /* tabel kan ontbreken op een heel oude database */ }
+    if (replies.length) {
+      const coll = {
+        '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
+        type: 'OrderedCollection',
+        'shaer:archive': true,
+        'shaer:inReplyTo': obj.id,
+        totalItems: replies.length,
+        orderedItems: replies.map((r) => ({
+          id: r.object_uri || undefined,
+          type: 'Note',
+          attributedTo: r.actor_uri || undefined,
+          inReplyTo: r.parent_uri || obj.id,
+          content: r.content || '',
+          published: toISO(r.published || r.created_at) || undefined,
+          'shaer:actorName': r.actor_name || undefined,
+          'shaer:actorHandle': r.actor_handle || undefined,
+        })),
+      };
+      bestanden.set(`replies/${post.id}.json`, Buffer.from(stableJson(coll), 'utf8'));
+      tellingen.replies += replies.length;
+    }
+  }
+
+  const files = {};
+  for (const pad of [...bestanden.keys()].sort()) files[pad] = sha256(bestanden.get(pad));
+  const manifest = {
+    formatVersion: FORMAT_VERSION,
+    generator: `klonkt/${opts.version || 'dev'}`,
+    exportedAt: opts.exportedAt || new Date().toISOString(),
+    origin,
+    actor: `${origin}/ap/users/${encodeURIComponent(site.slug)}`,
+    site: { slug: site.slug, title: site.title || site.slug },
+    counts: tellingen,
+    files,
+  };
+  bestanden.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
+
+  return { files: bestanden, manifest, counts: tellingen, missing: ontbrekend };
+}
+
+// ── Zip, store-only en deterministisch ────────────────────────────
+// Geen nieuwe afhankelijkheid, en zonder compressie is byte-gelijkheid geen
+// kwestie van vertrouwen in de instellingen van een bibliotheek. De mtime is
+// vast (1980-01-01, de nul van het zip-formaat) om dezelfde reden.
+
+const _crcTabel = (() => {
+  const t = new Int32Array(256);
+  for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c; }
+  return t;
+})();
+function crc32(buf) {
+  let c = 0 ^ -1;
+  for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ _crcTabel[(c ^ buf[i]) & 0xFF];
+  return (c ^ -1) >>> 0;
+}
+
+export function zipArchive(files) {
+  const paden = [...files.keys()].sort();
+  const lokaal = [];
+  const centraal = [];
+  let offset = 0;
+  for (const pad of paden) {
+    const naam = Buffer.from(pad, 'utf8');
+    const data = files.get(pad);
+    const crc = crc32(data);
+    const lh = Buffer.alloc(30);
+    lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(0x0800, 6);
+    lh.writeUInt16LE(0, 8);                       // store, geen compressie
+    lh.writeUInt16LE(0, 10); lh.writeUInt16LE(33, 12);   // vaste tijd: 1980-01-01
+    lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(data.length, 18); lh.writeUInt32LE(data.length, 22);
+    lh.writeUInt16LE(naam.length, 26); lh.writeUInt16LE(0, 28);
+    lokaal.push(lh, naam, data);
+
+    const ch = Buffer.alloc(46);
+    ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(20, 4); ch.writeUInt16LE(20, 6);
+    ch.writeUInt16LE(0x0800, 8); ch.writeUInt16LE(0, 10);
+    ch.writeUInt16LE(0, 12); ch.writeUInt16LE(33, 14);
+    ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(data.length, 20); ch.writeUInt32LE(data.length, 24);
+    ch.writeUInt16LE(naam.length, 28); ch.writeUInt16LE(0, 30); ch.writeUInt16LE(0, 32);
+    ch.writeUInt16LE(0, 34); ch.writeUInt16LE(0, 36); ch.writeUInt32LE(0, 38);
+    ch.writeUInt32LE(offset, 42);
+    centraal.push(ch, naam);
+    offset += 30 + naam.length + data.length;
+  }
+  const cd = Buffer.concat(centraal);
+  const eocd = Buffer.alloc(22);
+  eocd.writeUInt32LE(0x06054b50, 0);
+  eocd.writeUInt16LE(paden.length, 8); eocd.writeUInt16LE(paden.length, 10);
+  eocd.writeUInt32LE(cd.length, 12); eocd.writeUInt32LE(offset, 16);
+  return Buffer.concat([...lokaal, cd, eocd]);
+}
+
+/** Schrijf het archief als losse bestanden naar een map. */
+export function writeArchiveDir(files, dir) {
+  for (const pad of [...files.keys()].sort()) {
+    const doel = path.join(dir, pad);
+    fs.mkdirSync(path.dirname(doel), { recursive: true });
+    fs.writeFileSync(doel, files.get(pad));
+  }
+}
Index: src/services/ArchiveImportService.js
===================================================================
--- src/services/ArchiveImportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/ArchiveImportService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,318 @@
+/**
+ * Import van een draagbaar inhoudsarchief (shaer-pmr).
+ *
+ * Leest wat docs/EXPORT-FORMAT.md beschrijft. Vier regels uit dat document zijn
+ * geen implementatiekeuze maar eis, en ze staan hier alle vier expliciet:
+ *
+ *   VERSIE EERST   Een hogere onbekende formatVersion wordt in zijn GEHEEL
+ *                  geweigerd. Een half begrepen herstel is erger dan geen
+ *                  herstel, want het ziet eruit alsof het gelukt is.
+ *   IDENTITEIT     De origin uit het manifest bepaalt of de AP-ids behouden
+ *                  blijven. Dat is geen vraag aan de gebruiker: een verkeerd
+ *                  antwoord publiceert objecten onder een id dat je niet beheert.
+ *   NIETS STILS    Ontbrekende media worden geteld en gemeld.
+ *   GEEN UITZENDING Geen Update de fediverse in. Verouderde kopieen elders
+ *                  rechttrekken is een aparte, bewuste actie.
+ *
+ * `readable/` wordt nooit gelezen. Dat is de hele reden dat het afgeleid is.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import zlib from 'zlib';
+import crypto from 'crypto';
+import { randomUUID } from 'crypto';
+import db from '../config/database.js';
+import { MEDIA_ROOT } from '../config/paths.js';
+import { FORMAT_VERSION } from './ArchiveExportService.js';
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+// De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
+// sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
+// volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
+const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
+
+// ── Inlezen ───────────────────────────────────────────────────────
+
+/** Lees een archiefmap in als pad -> Buffer. */
+export function readArchiveDir(dir) {
+  const files = new Map();
+  const loop = (sub) => {
+    for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
+      const rel = sub ? `${sub}/${naam.name}` : naam.name;
+      if (naam.isDirectory()) loop(rel);
+      else files.set(rel, fs.readFileSync(path.join(dir, rel)));
+    }
+  };
+  loop('');
+  return files;
+}
+
+/**
+ * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
+ * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
+ */
+export function readArchiveZip(buf) {
+  const files = new Map();
+  const eocd = (() => {
+    for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
+    return -1;
+  })();
+  if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
+  const aantal = buf.readUInt16LE(eocd + 10);
+  let p = buf.readUInt32LE(eocd + 16);
+  for (let n = 0; n < aantal; n++) {
+    if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
+    const methode = buf.readUInt16LE(p + 10);
+    const gecomp = buf.readUInt32LE(p + 20);
+    const naamLen = buf.readUInt16LE(p + 28);
+    const extraLen = buf.readUInt16LE(p + 30);
+    const commentLen = buf.readUInt16LE(p + 32);
+    const lokaalOffset = buf.readUInt32LE(p + 42);
+    const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
+    const lNaam = buf.readUInt16LE(lokaalOffset + 26);
+    const lExtra = buf.readUInt16LE(lokaalOffset + 28);
+    const start = lokaalOffset + 30 + lNaam + lExtra;
+    const rauw = buf.subarray(start, start + gecomp);
+    if (!naam.endsWith('/')) {
+      files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
+    }
+    p += 46 + naamLen + extraLen + commentLen;
+  }
+  return files;
+}
+
+export function readArchive(bron) {
+  const st = fs.statSync(bron);
+  return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
+}
+
+// ── Importeren ────────────────────────────────────────────────────
+
+/** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
+function veiligMediaPad(urlPad) {
+  if (!urlPad || !urlPad.startsWith('/media/')) return null;
+  const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
+  const root = path.resolve(MEDIA_ROOT);
+  return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
+}
+
+/** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
+function padVanOrigineel(u) {
+  const s = String(u || '');
+  if (s.startsWith('/media/')) return s;
+  try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
+}
+
+/**
+ * Waar deze bijlage komt te staan, als site-relatief pad.
+ *
+ * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
+ * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
+ * plek onder /media. Zonder een bestemming zou het bestand wel worden
+ * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
+ * en verwijzen de kolommen daarheen.
+ */
+function bestemming(a) {
+  return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
+}
+
+/**
+ * Zet een archief terug in een site.
+ *
+ * @param {Map<string,Buffer>} files  het ingelezen archief
+ * @param {object} opts  { slug, dryRun, overwrite, origin }
+ */
+export function importArchive(files, opts = {}) {
+  const rapport = {
+    formatVersion: null, origin: null, idsBehouden: null,
+    posts: 0, overgeslagen: 0, overschreven: 0,
+    replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
+  };
+
+  const manifestBuf = files.get('manifest.json');
+  if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
+  const manifest = JSON.parse(manifestBuf.toString('utf8'));
+  rapport.formatVersion = manifest.formatVersion;
+
+  // VERSIE EERST, voordat er ook maar iets gelezen wordt.
+  if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
+  if (manifest.formatVersion > FORMAT_VERSION) {
+    throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
+  }
+
+  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
+  if (!site) throw new Error(`onbekende site: ${opts.slug}`);
+  const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  rapport.origin = manifest.origin || null;
+
+  // IDENTITEIT. Gelijke origin -> de AP-ids blijven, en daarmee vinden de boosts
+  // en antwoorden die er al naar wijzen hun post terug. Anders nieuwe ids, want
+  // een id op andermans domein publiceren is een vervalsingsoppervlak en andere
+  // servers halen het daar toch op.
+  const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
+  rapport.idsBehouden = idsBehouden;
+  if (!idsBehouden) {
+    rapport.waarschuwingen.push(
+      `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): nieuwe AP-ids, de oude blijven als verwijzing staan`,
+    );
+  }
+
+  const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
+  const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
+  const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
+  const idKaart = new Map();     // oud post-id -> nieuw post-id
+
+  const schrijf = [];            // alles eerst uitrekenen, dan in EEN transactie
+
+  for (const pad of postPaden) {
+    const o = JSON.parse(files.get(pad).toString('utf8'));
+    const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
+    const nieuwId = idsBehouden ? oudId : randomUUID();
+    idKaart.set(oudId, nieuwId);
+
+    const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
+    if (botsing && !opts.overwrite) {
+      // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
+      // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
+      // idempotent maakt.
+      rapport.overgeslagen += 1;
+      continue;
+    }
+    if (botsing) rapport.overschreven += 1;
+
+    // Media: terug naar hun oorspronkelijke plek onder /media, want de content
+    // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
+    // op een ander domein.
+    for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
+      if (a['shaer:availability'] === 'missing') {
+        rapport.mediaMissing += 1;
+        rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
+        continue;
+      }
+      const bytes = files.get(a.url);
+      if (!bytes) {
+        // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
+        // archief, geen ontbrekende media -- apart melden, niet stil optellen.
+        rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
+        continue;
+      }
+      if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
+        rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
+        continue;
+      }
+      const doel = veiligMediaPad(bestemming(a));
+      if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
+      schrijf.push({ soort: 'media', doel, bytes });
+      rapport.media += 1;
+    }
+
+    schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o });
+    rapport.posts += 1;
+  }
+
+  // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
+  for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
+    const coll = JSON.parse(files.get(pad).toString('utf8'));
+    if (coll['shaer:archive'] !== true) {
+      rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
+      continue;
+    }
+    const oudId = path.basename(pad, '.json');
+    const postId = idKaart.get(oudId);
+    if (!postId) continue;                       // post overgeslagen -> antwoorden ook
+    for (const it of (coll.orderedItems || [])) {
+      schrijf.push({ soort: 'reply', postId, it });
+      rapport.replies += 1;
+    }
+  }
+
+  if (opts.dryRun) return rapport;
+
+  // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
+  // uitkomst, want dan lijkt het gelukt.
+  const insPost = db.prepare(`INSERT OR REPLACE INTO posts
+    (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
+     pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
+     content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
+    VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
+     @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
+     @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
+  const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
+    (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
+    VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
+
+  db.transaction(() => {
+    for (const s of schrijf) {
+      if (s.soort === 'media') {
+        fs.mkdirSync(path.dirname(s.doel), { recursive: true });
+        fs.writeFileSync(s.doel, s.bytes);
+        continue;
+      }
+      if (s.soort === 'reply') {
+        insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
+          s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
+        continue;
+      }
+      const o = s.obj;
+      const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
+      // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
+      // bestand er wel, maar komt de post zonder cover en zonder speler terug --
+      // en dat zie je pas als je alle kolommen vergelijkt.
+      const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
+      const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
+      const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
+      const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
+        const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
+        return {
+          url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
+          poster: poster ? padVan(poster) : undefined,
+        };
+      }).filter((a) => a.url);
+      insPost.run({
+        id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
+        title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
+        status: o['shaer:status'] || 'draft',
+        cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
+        cover_video_url: padVan(metRol('coverVideo')),
+        c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
+        pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
+        tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
+        published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
+        noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
+        fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
+        language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
+        content_warning: o.summary || null,
+        poll_json: opties ? JSON.stringify({ multiple: Array.isArray(o.anyOf), options: opties.map((x) => ({ name: x.name })), endTime: o.endTime || null, closed: !!o.closed }) : null,
+        quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
+        ap_visibility: o['shaer:apVisibility'] || null,
+        paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
+        view_count: o['shaer:viewCount'] || 0,
+      });
+      // Gehoste audio terug: [[track:]] in de content valt anders op niets terug.
+      for (const t of (o['shaer:audio'] || [])) {
+        const trackId = String(t['shaer:ref'] || '').replace(/^\[\[track:|\]\]$/g, '');
+        if (!trackId) continue;
+        let mediaId = null;
+        const bij = (o.attachment || []).find((a) => a.url === t['shaer:media']);
+        const doel = bij && veiligMediaPad(bestemming(bij));
+        if (doel) {
+          mediaId = randomUUID();
+          try {
+            db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+              .run(mediaId, site.id, path.basename(doel), bij.mediaType || 'audio/mpeg', (files.get(bij.url) || []).length || 0, doel);
+          } catch { mediaId = null; }
+        }
+        try {
+          db.prepare(`INSERT OR REPLACE INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, credit, license, link_spotify, link_youtube, link_soundcloud)
+                      VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
+            .run(trackId, site.id, t.name || 'zonder titel', t.artist || null, t.album || null, t.duration || null,
+              mediaId, t.credit || null, t.license || null,
+              ...['spotify', 'youtube', 'soundcloud'].map((k) => (t.url || []).find((u) => String(u).includes(k)) || null));
+        } catch { /* geen audio-tabellen op deze installatie */ }
+      }
+    }
+  })();
+
+  return rapport;
+}
Index: src/services/ArchiveRecoveryService.js
===================================================================
--- src/services/ArchiveRecoveryService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/ArchiveRecoveryService.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,250 @@
+/**
+ * Herstel uit de tijdlijn-cache van een ANDERE Klonkt (shaer-l1v).
+ *
+ * De aanleiding: boiert.eu verloor zijn database. De posts staan nog in de
+ * ap_timeline van instances die boiert volgen, en die tabel sleutelt op de
+ * OORSPRONKELIJKE AP-object-URI. De identiteiten overleven dus, en dat is het
+ * verschil tussen herstellen en opnieuw posten: boosts, likes en antwoorden
+ * elders wijzen naar die ids.
+ *
+ * Dit maakt geen posts aan. Het maakt een ARCHIEF in het formaat uit
+ * docs/EXPORT-FORMAT.md, zodat het door dezelfde importer gaat als een gewone
+ * export -- inclusief droogloop, versiecontrole en de regel rond AP-ids. Een
+ * apart herstelpad zou een tweede implementatie zijn van iets dat al bestaat.
+ *
+ * WAT ER PRINCIPIEEL NIET IN ZIT, en dat hoort in de verwachting te staan
+ * voordat iemand eraan begint:
+ *
+ *   - ANTWOORDEN van de verloren site zelf. belongsInTimeline() weigert alles
+ *     met een inReplyTo, dus die zijn nooit in een tijdlijn-cache beland.
+ *   - alles van VOOR het moment dat de bron ging volgen.
+ *   - CONCEPTEN. Nooit gefedereerd, dus nergens gecachet.
+ *   - de content is de FEDERATIE-projectie: gesaneerd door de sanitizer van de
+ *     bron, met de titel in de tekst gebakken en de afbeeldingen uit de body
+ *     gehaald. Waar ze in de tekst stonden is niet te herstellen.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import crypto from 'crypto';
+import Database from 'better-sqlite3';
+import { stableJson, FORMAT_VERSION } from './ArchiveExportService.js';
+
+const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
+const MIME_BY_EXT = {
+  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
+  webp: 'image/webp', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm',
+  mov: 'video/quicktime', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
+};
+const extOf = (u) => ((String(u).split('?')[0].match(/\.(\w+)$/) || [])[1] || '').toLowerCase();
+const mimeOf = (u, opgegeven) => opgegeven || MIME_BY_EXT[extOf(u)] || 'application/octet-stream';
+const as2TypeOf = (m) => (m.startsWith('video/') ? 'Video' : m.startsWith('audio/') ? 'Audio' : 'Image');
+
+/**
+ * De titel terugvissen uit de tekst.
+ *
+ * buildNote() zet de titel als eerste alinea in de content -- `<p><strong>...`
+ * -- omdat Mastodon `name` negeert. In de cache staat dus de gefedereerde vorm,
+ * en zonder deze stap komt elke post titelloos terug met zijn titel als vetgedrukte
+ * eerste regel in de body.
+ *
+ * Er is GEEN sluitend signaal. De slug is niet van de titel afgeleid (op echte
+ * data: titel "Back to 1987!", slug "waiting-on-you"), dus we moeten op de vorm
+ * afgaan: een openende alinea die niets anders bevat dan vetgedrukte platte
+ * tekst. Een post die echt zo begint verliest die regel naar zijn titel. Vandaar
+ * dat de beller een lijst terugkrijgt van alles wat is losgetrokken -- dat hoort
+ * een mens na te lopen, niet een script.
+ */
+export function splitsTitel(html) {
+  const m = String(html || '').match(/^\s*<p>\s*<strong>([^<>]+)<\/strong>\s*<\/p>/i);
+  if (!m) return { titel: null, rest: html || '' };
+  const titel = m[1].replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
+  if (!titel) return { titel: null, rest: html || '' };
+  return { titel, rest: String(html).slice(m[0].length) };
+}
+
+/**
+ * Van een URL naar een bestand op de geredde schijf.
+ *
+ * Twee routes, en de tweede was bijna vergeten: gewone media gaan via /media/ op
+ * MEDIA_ROOT, maar GEHOSTE AUDIO gaat via /audio/stream/<bestandsnaam> op
+ * AUDIO_DIR -- een andere map. Op echte cachedata van een muzieksite is dat geen
+ * randgeval maar de helft van de bijlagen.
+ */
+function schijfPad(url, origin, mediaRoot, audioRoot) {
+  let p = String(url || '');
+  if (/^https?:/i.test(p)) {
+    try {
+      const u = new URL(p);
+      if (origin && `${u.protocol}//${u.host}` !== origin) return null;
+      p = u.pathname;
+    } catch { return null; }
+  }
+  const onder = (root, rest) => {
+    if (!root) return null;
+    const abs = path.resolve(root, decodeURIComponent(rest));
+    const r = path.resolve(root);
+    return (abs !== r && abs.startsWith(`${r}${path.sep}`)) ? abs : null;
+  };
+  if (p.startsWith('/media/')) return onder(mediaRoot, p.slice('/media/'.length));
+  if (p.startsWith('/audio/stream/')) return onder(audioRoot, p.slice('/audio/stream/'.length));
+  return null;
+}
+
+const parse = (s, val = null) => { try { return JSON.parse(s) || val; } catch { return val; } };
+
+/**
+ * Bouw een archief uit een of meer tijdlijn-caches.
+ *
+ * @param {object} opts
+ *   sources   paden naar de database(s) van instances die de verloren site volgen
+ *   actorUri  de actor van de verloren site, bv. https://boiert.eu/ap/users/boiert
+ *   mediaRoot de geredde mediamap van de verloren site (optioneel)
+ *   houdTitelInTekst  laat de titel staan waar hij staat
+ */
+export function recoverFromCache(opts = {}) {
+  const { sources = [], actorUri, mediaRoot = null, houdTitelInTekst = false } = opts;
+  // AUDIO_PATH staat naast MEDIA_PATH, niet erin. Zonder eigen opgave nemen we de
+  // buurmap van de mediamap, want dat is de standaardindeling van storage/.
+  const audioRoot = opts.audioRoot || (mediaRoot ? path.join(path.dirname(path.resolve(mediaRoot)), 'audio') : null);
+  if (!actorUri) throw new Error('actorUri is verplicht: zonder actor weten we niet wiens posts we redden');
+  const origin = (opts.origin || (() => { try { const u = new URL(actorUri); return `${u.protocol}//${u.host}`; } catch { return ''; } })()).replace(/\/+$/, '');
+  if (!origin) throw new Error('kan de origin niet afleiden uit de actorUri');
+
+  const rapport = {
+    bronnen: [], posts: 0, titels: [], media: 0, mediaMissing: 0, gemist: [],
+    overgeslagen: 0, oudste: null, nieuwste: null, waarschuwingen: [],
+  };
+
+  // Beste rij per AP-id. Meerdere bronnen dekken verschillende periodes, en
+  // dezelfde post kan in meer dan een tijdlijn staan; de rijkste versie wint.
+  const beste = new Map();
+  for (const bron of sources) {
+    let n = 0;
+    let sdb;
+    try { sdb = new Database(bron, { readonly: true, fileMustExist: true }); }
+    catch (e) { rapport.waarschuwingen.push(`${bron}: niet te openen (${e.message})`); continue; }
+    let rijen = [];
+    try { rijen = sdb.prepare('SELECT * FROM ap_timeline WHERE author_uri = ?').all(actorUri); }
+    catch (e) { rapport.waarschuwingen.push(`${bron}: geen bruikbare ap_timeline (${e.message})`); }
+    for (const r of rijen) {
+      // Een boost VAN een ander staat op naam van de oorspronkelijke auteur, dus
+      // author_uri filtert die al weg. Een boost van ONZE post door een ander is
+      // wel van ons -- die houden we, maar zonder de booster.
+      const vorige = beste.get(r.id);
+      if (!vorige || String(r.content || '').length > String(vorige.content || '').length) beste.set(r.id, r);
+      n += 1;
+    }
+    sdb.close();
+    rapport.bronnen.push({ pad: bron, rijen: n });
+  }
+
+  const files = new Map();
+  const ids = [...beste.keys()].sort();
+
+  for (const apId of ids) {
+    const r = beste.get(apId);
+    const postId = decodeURIComponent(String(apId).split('/ap/notes/')[1] || '');
+    if (!postId) { rapport.overgeslagen += 1; continue; }
+    let slug = postId;
+    try { const u = new URL(r.url || ''); slug = decodeURIComponent(u.pathname.replace(/^\//, '')) || postId; } catch { /* val terug op het id */ }
+
+    const gesplitst = houdTitelInTekst ? { titel: null, rest: r.content || '' } : splitsTitel(r.content);
+    if (gesplitst.titel) rapport.titels.push({ slug, titel: gesplitst.titel });
+
+    // Bijlagen. De cache bewaart alleen URL's; de VOLGORDE is die van buildNote,
+    // waarin de cover voorop gaat. Meer signaal is er niet, dus de eerste krijgt
+    // de rol cover en de rest wordt bijlage. Waar ze in de tekst stonden is bij
+    // het federeren verloren gegaan en komt niet terug.
+    const attachments = [];
+    const lijst = parse(r.media_json, []) || [];
+    lijst.forEach((m, i) => {
+      const url = m && (m.url || m.href);
+      if (!url) return;
+      const mime = mimeOf(url, m.type && String(m.type).includes('/') ? m.type : null);
+      const rol = i === 0 ? 'cover' : 'c2s';
+      const bestand = schijfPad(url, origin, mediaRoot, audioRoot);
+      let bytes = null;
+      if (bestand) { try { bytes = fs.readFileSync(bestand); } catch { bytes = null; } }
+      if (bytes) {
+        const hash = sha256(bytes);
+        const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`;
+        if (!files.has(naam)) { files.set(naam, bytes); rapport.media += 1; }
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
+          url: naam, 'shaer:availability': 'included',
+          'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
+          'shaer:sha256': hash, 'shaer:role': rol,
+        });
+      } else {
+        rapport.mediaMissing += 1;
+        rapport.gemist.push({ slug, url });
+        attachments.push({
+          type: as2TypeOf(mime), mediaType: mime, name: m.name || m.alt || undefined,
+          url: /^https?:/i.test(url) ? url : `${origin}${url}`,
+          'shaer:availability': 'missing',
+          'shaer:originalUrl': /^https?:/i.test(url) ? url : `${origin}${url}`,
+          'shaer:role': rol,
+        });
+      }
+    });
+
+    const poll = parse(r.poll_json);
+    const quote = parse(r.quote_json);
+    const opties = poll && Array.isArray(poll.options) && poll.options.length >= 2
+      ? poll.options.map((o) => ({ type: 'Note', name: String(o && o.name != null ? o.name : o) })) : null;
+
+    const obj = {
+      '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#', toot: 'http://joinmastodon.org/ns#', Hashtag: 'as:Hashtag', sensitive: 'as:sensitive' }],
+      id: apId,
+      type: opties ? 'Question' : (gesplitst.titel ? 'Article' : 'Note'),
+      attributedTo: actorUri,
+      name: gesplitst.titel || undefined,
+      content: gesplitst.rest,
+      summary: r.cw || undefined,
+      sensitive: r.nsfw ? true : undefined,
+      published: r.published || undefined,
+      url: r.url || `${origin}/${encodeURIComponent(slug)}`,
+      attachment: attachments.length ? attachments : undefined,
+      ...(opties ? (poll.multiple ? { anyOf: opties } : { oneOf: opties }) : {}),
+      endTime: opties ? (poll.endTime || undefined) : undefined,
+      closed: (opties && poll.closed) ? true : undefined,
+      quoteUrl: (quote && (quote.url || quote.uri || quote.id)) || undefined,
+      'shaer:slug': slug,
+      'shaer:status': 'published',      // alles wat gefedereerd is, was gepubliceerd
+      'shaer:recoveredFrom': 'timeline-cache',
+    };
+    files.set(`posts/${postId}.json`, Buffer.from(stableJson(obj), 'utf8'));
+    files.set(`readable/${slug}.md`, Buffer.from(
+      `---\ntitle: ${JSON.stringify(gesplitst.titel || slug)}\nslug: ${JSON.stringify(slug)}\ndate: ${r.published || ''}\nrecovered: timeline-cache\n---\n${gesplitst.rest}\n`, 'utf8'));
+    rapport.posts += 1;
+    if (r.published) {
+      if (!rapport.oudste || r.published < rapport.oudste) rapport.oudste = r.published;
+      if (!rapport.nieuwste || r.published > rapport.nieuwste) rapport.nieuwste = r.published;
+    }
+  }
+
+  const bestandsHashes = {};
+  for (const pad of [...files.keys()].sort()) bestandsHashes[pad] = sha256(files.get(pad));
+  const manifest = {
+    formatVersion: FORMAT_VERSION,
+    generator: `klonkt-recovery/${opts.version || 'dev'}`,
+    exportedAt: opts.exportedAt || new Date().toISOString(),
+    origin,
+    actor: actorUri,
+    site: { slug: opts.slug || '', title: opts.title || '' },
+    counts: { posts: rapport.posts, replies: 0, media: rapport.media, mediaMissing: rapport.mediaMissing },
+    files: bestandsHashes,
+    // Zodat niemand dit later voor een gewone export aanziet: dit archief is
+    // gereconstrueerd uit andermans cache en is per definitie onvolledig.
+    'shaer:recovered': {
+      from: 'timeline-cache',
+      sources: rapport.bronnen.map((b) => path.basename(b.pad)),
+      window: { oldest: rapport.oudste, newest: rapport.nieuwste },
+      missing: ['replies by this actor', 'posts from before the source followed', 'drafts'],
+    },
+  };
+  files.set('manifest.json', Buffer.from(stableJson(manifest), 'utf8'));
+
+  return { files, manifest, rapport };
+}
Index: src/services/guardianship/delivery.js
===================================================================
--- src/services/guardianship/delivery.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/delivery.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -41,5 +41,5 @@
 // guardian on any instance receives it as a private mention (the ward
 // call-for-help path).
-export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
+export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark }) {
   const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
           getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
@@ -83,6 +83,16 @@
     return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
   }).join('');
+  // Rijk antwoord: `html` is de HTML uit de reply-editor, hier gesaneerd; `text`
+  // blijft de platte versie (het `source`-veld en de no-JS-fallback). Levert de
+  // sanitizer niets bruikbaars op, dan valt hij terug op de escaped tekst --
+  // een leeggepoetste editor mag geen leeg bericht versturen.
+  const richClean = html ? deps.sanitizeHtml(String(html)) : '';
+  const rich = richClean && deps.htmlToPlainText(richClean).trim() ? richClean : '';
   const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
-  const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
+  // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het
+  // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint.
+  const content = rich
+    ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}`
+    : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
   const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
   // Attachments: same rules as deliverReply (own /media/ uploads only,
@@ -99,4 +109,11 @@
   const row = getOutboxRow(id);
   const note = buildReplyNote(base, site, row);
+  // Markering op een hulpvraag (shaer-lgo): een gewone directe note die er een
+  // shaer:-eigenschap bij draagt, net als de zwaai. Zo reist het over dezelfde
+  // bezorging, ziet de ward het als bericht ("er komt iemand"), en houden de
+  // mede-guardians er staat aan over.
+  if (helpMark && helpMark.noteUri) {
+    note[helpMark.kind === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup'] = helpMark.noteUri;
+  }
   const create = {
     '@context': AP_CONTEXT,
Index: src/services/guardianship/follows.js
===================================================================
--- src/services/guardianship/follows.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/follows.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -81,6 +81,7 @@
     _r = {
       ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
-        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at)
-        VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
+        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json,
+         direction, target_uri, target_handle, created_at)
+        VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
       get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
       bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
@@ -91,7 +92,55 @@
 }
 
+/**
+ * De guardian-zijdige kopie van een gate-verzoek op een REMOTE ward.
+ *
+ * `direction` is niet cosmetisch (shaer-jdb). Bij een INKOMENDE is de follower
+ * iemand anders en de ward het doel. Bij een UITGAANDE is de ward zelf de
+ * follower en staat het doel in het Follow-object -- die werd hiervoor
+ * opgeslagen als "deze ward wil deze ward volgen", met het doel weggegooid.
+ */
 export function recordReview(guardianSlug, r) {
-  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null);
+  const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming';
+  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null,
+    r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null);
   return rstmts().get.get(guardianSlug, r.id);
+}
+
+/**
+ * Een openstaande review als wachtrij-item, in dezelfde vorm die de clients al
+ * lezen (offers en outgoing-follows doen het net zo).
+ */
+export function reviewQueueItem(r, me, guardianCount) {
+  // guardianCount blijft WEG als we hem niet kennen. Bij een remote ward wordt
+  // de guardian-set op diens eigen server bijgehouden, en 0 sturen zou lezen als
+  // "dit kind heeft geen guardians" -- het tegenovergestelde van onbekend.
+  const stemmen = (() => {
+    try { return db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(r.id); }
+    catch { return []; }
+  })();
+  const uitgaand = r.direction === 'outgoing';
+  return {
+    id: r.id,
+    type: 'Follow',
+    // Bij een uitgaande is de WARD de volger; bij een inkomende is dat de vreemde.
+    actor: uitgaand ? r.ward_uri : r.follower_uri,
+    object: uitgaand ? (r.target_uri || '') : r.ward_uri,
+    'shaer:direction': uitgaand ? 'outgoing' : 'incoming',
+    'shaer:ward': r.ward_uri,
+    'shaer:target': uitgaand ? (r.target_uri || undefined) : undefined,
+    'shaer:targetHandle': uitgaand ? (r.target_handle || undefined) : undefined,
+    'shaer:follower': uitgaand ? undefined : r.follower_uri,
+    'shaer:followerHandle': uitgaand ? undefined : (r.follower_handle || undefined),
+    'shaer:quorum': 'all',
+    'shaer:approvals': stemmen.filter((x) => x.decision === 'approve').length,
+    'shaer:guardianCount': guardianCount || undefined,
+    'shaer:myVote': stemmen.some((x) => x.guardian_uri === me),
+    published: r.created_at,
+  };
+}
+
+/** De openstaande reviews van een guardian, per richting. */
+export function listReviewsByDirection(guardianSlug, direction) {
+  return listReviews(guardianSlug).filter((r) => (r.direction === 'outgoing' ? 'outgoing' : 'incoming') === direction);
 }
 export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
@@ -102,3 +151,4 @@
   recordPending, getPending, listForWard, decide, remove,
   recordReview, getReview, listReviews, removeReview,
+  listReviewsByDirection, reviewQueueItem,
 };
Index: src/services/guardianship/gated.js
===================================================================
--- src/services/guardianship/gated.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/gated.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -60,4 +60,64 @@
   'shaer:externalPlayback': 'external_playback',
 };
+/**
+ * De gates die deze Klonkt kent, met hun SOORT.
+ *
+ * Wat gated wordt is een ontwerpkeuze van de implementatie: de FEP levert het
+ * mechanisme (voorstel, tally, settle) en een paar voorbeelden, niet de lijst.
+ * Deze catalogus is die lijst, op een plek. Een gate erbij hoort een regel data
+ * te zijn en geen nieuw stuk scherm.
+ *
+ * `kind` is niet decoratief. De gates verschillen in hoe ze werken en dat mag
+ * een guardian niet hoeven raden:
+ *
+ *   setting     een stand, aan of uit, terug te draaien
+ *   perRequest  geen stand maar een stroom beslissingen (5.3 volgverzoeken)
+ *   handover    draagt gezag OVER; onomkeerbaar zodra de ward hem gebruikt
+ *
+ * `needs` is de trap uit shaer-ahy: zien < afspelen. Je kunt niet afspelen wat
+ * je niet mag zien, dus dat tweede is pas te bewegen als het eerste openstaat.
+ */
+export const GATE_CATALOGUE = [
+  { feature: 'shaer:externalEmbeds', kind: 'setting', reversible: true },
+  { feature: 'shaer:externalPlayback', kind: 'setting', reversible: true, needs: 'shaer:externalEmbeds' },
+  // Altijd aan voor een ward (5.3): niet te verzetten, wel te tonen. Een paneel
+  // dat alleen verstelbare dingen laat zien verzwijgt de helft van wat er geldt.
+  { feature: 'shaer:follows', kind: 'perRequest', reversible: true, fixed: true },
+];
+
+/**
+ * De gates van een ward als rijen voor het paneel. Puur, zodat de regels
+ * getoetst kunnen worden zonder database of scherm.
+ *
+ * @param settings       {feature: true|false|null} -- null is ONBEKEND, niet uit
+ * @param guardianCount  aantal guardians, of null als we het niet weten
+ * @param proposals      [{feature, value, status}] lopende voorstellen
+ * @param waiting        {feature: aantal} wat er per gate op een besluit wacht
+ */
+export function gateRows({ settings = {}, guardianCount = null, proposals = [], waiting = {} } = {}) {
+  return GATE_CATALOGUE.map((g) => {
+    const value = Object.prototype.hasOwnProperty.call(settings, g.feature) ? settings[g.feature] : null;
+    // De trap: het bovenliggende moet OPEN staan. Onbekend telt niet als dicht --
+    // bij een ward elders kennen we de stand niet, en verbergen betekende daar
+    // ooit dat een voorstel nooit geopend kon worden.
+    const blockedBy = g.needs && settings[g.needs] === false ? g.needs : null;
+    return {
+      feature: g.feature,
+      kind: g.kind,
+      reversible: !!g.reversible,
+      value,
+      // Vast staat vast: tonen mag, verzetten niet.
+      adjustable: !g.fixed && !blockedBy,
+      blockedBy: blockedBy || undefined,
+      // Zonder bekend aantal guardians GEEN drempel verzinnen. Nul of een gok
+      // leest als een feit, en dit is precies waar een guardian op afgaat.
+      threshold: (guardianCount && guardianCount > 0)
+        ? { need: thresholdFor(guardianCount), of: guardianCount } : null,
+      proposal: proposals.find((p) => p.feature === g.feature) || undefined,
+      waiting: waiting[g.feature] || undefined,
+    };
+  });
+}
+
 export function featureColumn(feature) {
   return Object.prototype.hasOwnProperty.call(FEATURES, feature) ? FEATURES[feature] : null;
@@ -239,4 +299,5 @@
 
 export default {
+  GATE_CATALOGUE, gateRows,
   tallyGatedSetting, thresholdFor, featureColumn, recordGatedVote, gatedProgress, GATED_WINDOW_MS,
   parseGatedSetting, buildGatedOffer, rememberGatedOffer, recallGatedOffer,
Index: src/services/guardianship/help.js
===================================================================
--- src/services/guardianship/help.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/services/guardianship/help.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,125 @@
+/**
+ * Wie er op een hulpvraag af is, en wanneer hij is afgesloten (shaer-lgo).
+ *
+ * Een hulpvraag (FEP-633c 5.2.1) gaat naar ALLE guardians van een kind, die op
+ * verschillende servers zitten. Zonder gedeelde staat denken er twee dat de
+ * ander het oppakt -- en dat is precies het scenario waar de reddingsboei voor
+ * bestaat.
+ *
+ * DE FAALSTAND IS HIER NIET VEILIG, en dat maakt dit anders dan elke gate. Bij
+ * een gate is "dicht" het veilige antwoord. Hier is de faalstand "iedereen denkt
+ * dat het geregeld is", en dat is gevaarlijker dan geen markering. Daaruit volgt
+ * de regel die overal in dit bestand terugkomt: bij twijfel is een hulpvraag
+ * OPEN.
+ *
+ * Twee besluiten van Bart (7-8) zitten in de vorm:
+ *
+ *   OPGEPIKT mag stapelen en vervalt niet, maar VEROUDERT zichtbaar. Twee mensen
+ *   die tegelijk reageren op een kind is geen probleem; twee die allebei niets
+ *   doen omdat de ander het "geclaimd" had, wel. En een signaal dat vanzelf
+ *   verdwijnt laat een hulpvraag er onaangeroerd uitzien terwijl er iemand mee
+ *   bezig is.
+ *
+ *   AFGEHANDELD kent geen terugdraai. Sluiten gebeurt met een stevige
+ *   bevestiging, en leeft de vraag daarna nog, dan wordt hij OPNIEUW GESTELD --
+ *   een nieuwe hulpvraag. Er wordt niets herschreven, er wordt toegevoegd.
+ */
+
+import db from '../../config/database.js';
+
+let _s = null;
+function stmts() {
+  if (!_s) {
+    _s = {
+      ins: db.prepare(`INSERT OR IGNORE INTO ap_help_state (note_uri, guardian_uri, kind, guardian_handle)
+                       VALUES (?,?,?,?)`),
+      forNote: db.prepare('SELECT * FROM ap_help_state WHERE note_uri = ? ORDER BY created_at ASC'),
+      forNotes: db.prepare('SELECT * FROM ap_help_state WHERE note_uri IN (SELECT value FROM json_each(?))'),
+    };
+  }
+  return _s;
+}
+
+/** Leg vast dat iemand deze hulpvraag heeft opgepikt of afgesloten. */
+export function record(noteUri, guardianUri, kind, handle = null) {
+  if (!noteUri || !guardianUri) return;
+  const k = kind === 'handled' ? 'handled' : 'pickup';
+  try { stmts().ins.run(noteUri, guardianUri, k, handle); } catch { /* nooit dragend */ }
+}
+
+/**
+ * De staat van een hulpvraag, uit zijn rijen. Puur, zodat de regels te toetsen
+ * zijn zonder database of scherm.
+ *
+ * `ageMs` is de leeftijd van de OUDSTE oppik. Daar tekent het scherm mee dat een
+ * signaal oud wordt -- niets verdwijnt, maar je ziet wel dat er misschien niets
+ * meer gebeurt.
+ */
+export function helpStatus(rows, now = Date.now()) {
+  const list = rows || [];
+  const pickups = list.filter((r) => r.kind === 'pickup');
+  const done = list.find((r) => r.kind === 'handled') || null;
+  const stamp = (r) => { const t = Date.parse(r.created_at); return isNaN(t) ? null : t; };
+  const oudste = pickups.map(stamp).filter((t) => t !== null).sort((a, b) => a - b)[0];
+  return {
+    // Namen erbij: "door wie" was de hele vraag. Zonder dat is het een vinkje.
+    pickedUpBy: pickups.map((r) => ({ uri: r.guardian_uri, handle: r.guardian_handle || null, at: r.created_at })),
+    handled: done ? { uri: done.guardian_uri, handle: done.guardian_handle || null, at: done.created_at } : null,
+    // Alleen betekenisvol zolang er niets is afgesloten.
+    ageMs: (!done && oudste) ? Math.max(0, now - oudste) : null,
+    // Waar het scherm op afgaat. Bij twijfel OPEN: een lege lijst, een rij die we
+    // niet kunnen lezen, wat dan ook -- alles wat geen expliciete afsluiting is,
+    // is een hulpvraag die nog op iemand wacht.
+    open: !done,
+  };
+}
+
+/** De staat van een hulpvraag zoals die nu is opgeslagen. */
+export function statusOf(noteUri, now = Date.now()) {
+  if (!noteUri) return helpStatus([], now);
+  try { return helpStatus(stmts().forNote.all(noteUri), now); } catch { return helpStatus([], now); }
+}
+
+/** Idem voor een hele lijst in een query, zodat een paneel geen N+1 wordt. */
+export function statusFor(noteUris, now = Date.now()) {
+  const uit = new Map();
+  const lijst = [...new Set((noteUris || []).filter(Boolean))];
+  if (!lijst.length) return uit;
+  let rijen = [];
+  try { rijen = stmts().forNotes.all(JSON.stringify(lijst)); } catch { rijen = []; }
+  const perNote = new Map();
+  for (const r of rijen) {
+    if (!perNote.has(r.note_uri)) perNote.set(r.note_uri, []);
+    perNote.get(r.note_uri).push(r);
+  }
+  for (const uri of lijst) uit.set(uri, helpStatus(perNote.get(uri) || [], now));
+  return uit;
+}
+
+/**
+ * De markering als bericht. Bewust een gewone directe note met een
+ * shaer:-markering, zoals de zwaai en de afwezigheidsmelding: dan reist het over
+ * de bestaande bezorging, en de WARD leest het als wat het is -- er komt iemand.
+ */
+export function markerNote({ id, me, noteUri, kind, to }) {
+  const k = kind === 'handled' ? 'handled' : 'pickup';
+  return {
+    id, type: 'Note', attributedTo: me, to,
+    [k === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup']: noteUri,
+    content: k === 'handled'
+      ? '<p>Deze hulpvraag is afgehandeld.</p>'
+      : '<p>Ik kijk hiernaar.</p>',
+  };
+}
+
+/** Leest een binnengekomen note als markering, of null als hij er geen is. */
+export function parseMarker(object) {
+  if (!object || typeof object !== 'object') return null;
+  const pickup = object['shaer:helpPickup'];
+  const handled = object['shaer:helpHandled'];
+  if (typeof handled === 'string' && handled) return { kind: 'handled', noteUri: handled };
+  if (typeof pickup === 'string' && pickup) return { kind: 'pickup', noteUri: pickup };
+  return null;
+}
+
+export default { record, helpStatus, statusOf, statusFor, markerNote, parseMarker };
Index: src/services/guardianship/index.js
===================================================================
--- src/services/guardianship/index.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/index.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -32,2 +32,4 @@
 // §5.6 gated settings (decided by the guardians, enforced by the ward's server)
 export * as gated from './gated.js';
+// 5.2.1: wie er op een hulpvraag af is en wanneer hij is afgesloten (shaer-lgo)
+export * as help from './help.js';
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/guardianship/queues.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -6,5 +6,5 @@
  *  - offers:  pending handshake offers where I am a party (§3), with the full
  *             accept tally so the client shows the right action
- *  - follows: pending gated follows for my wards (§5.3) — Fase 2, empty for now
+ *  - follows: pending gated follows ON my wards (§5.3), Fase 2 (shaer-jdb)
  *  - wards:   my committed wards
  */
@@ -13,4 +13,5 @@
 import * as availability from './availability.js';
 import * as outgoing from './outgoing.js';
+import * as follows from './follows.js';
 import * as handshake from './handshake.js';
 
@@ -35,12 +36,57 @@
 }
 
-/** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */
-export function followsCollection(id) {
-  return collection(id, []);
+/**
+ * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2,
+ * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar
+ * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag.
+ *
+ * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog)
+ * op deze:
+ *   - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward
+ *   - ap_pending_follows: een ward op deze instance
+ * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan.
+ */
+export function followsCollection(id, slug, me) {
+  const items = follows.listReviewsByDirection(slug, 'incoming')
+    .map((r) => follows.reviewQueueItem(r, me));
+  for (const w of relations.listWards(slug)) {
+    const wardSlug = slugOf(w.other_uri);
+    if (!wardSlug) continue;
+    for (const p of follows.listForWard(wardSlug)) {
+      items.push({
+        id: p.id, type: 'Follow', actor: p.follower_uri, object: w.other_uri,
+        'shaer:direction': 'incoming', 'shaer:ward': w.other_uri,
+        'shaer:follower': p.follower_uri, 'shaer:followerHandle': p.follower_handle || undefined,
+        'shaer:quorum': p.quorum || 'any', published: p.created_at,
+      });
+    }
+  }
+  return collection(id, items);
 }
 
-/** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */
+/** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */
+function slugOf(uri) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base || !String(uri || '').startsWith(`${base}/ap/users/`)) return null;
+  return decodeURIComponent(String(uri).slice(`${base}/ap/users/`.length).split(/[/?#]/)[0]) || null;
+}
+
+/**
+ * §5.3 uitgaand. Twee lezers, een wachtrij, en dat kan omdat §1 een ward en een
+ * guardian wederzijds uitsluit: je bent het een of het ander.
+ *
+ *   ALS WARD      wat IK wil volgen en waar mijn guardians nog over moeten
+ *   ALS GUARDIAN  wat mijn WARDS willen volgen en waar IK over moet (shaer-jdb)
+ *
+ * Dat tweede ontbrak. De wachtrij serveerde alleen listForWard(slug), en voor
+ * een guardian is dat per definitie leeg -- dus het scherm "Your wards want to
+ * follow" kon nooit iets tonen.
+ */
 export function outgoingFollowsCollection(id, slug, me) {
-  return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me)));
+  const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me));
+  for (const r of follows.listReviewsByDirection(slug, 'outgoing')) {
+    items.push(follows.reviewQueueItem(r, me));
+  }
+  return collection(id, items);
 }
 
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/services/i18n.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -67,5 +67,5 @@
     'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer',
     'push.t': 'Notificaties', 'push.intro': 'Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten, ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo min mogelijk inhoud mee.', 'push.unavailable': 'Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).', 'push.unsupported': 'Deze browser ondersteunt geen push-notificaties.', 'push.ios_hint': 'Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat: deel-knop, dan "Zet op beginscherm", en open de site daarna vanaf daar.', 'push.this_device': 'Dit apparaat:', 'push.checking': 'controleren…', 'push.state_on': 'meldingen staan aan', 'push.state_off': 'meldingen staan uit', 'push.state_denied': 'geblokkeerd in de browserinstellingen', 'push.state_unknown': 'status onbekend', 'push.state_unsupported': 'niet ondersteund', 'push.enable': 'Zet aan op dit apparaat', 'push.disable': 'Zet uit', 'push.test': 'Stuur testmelding', 'push.what': 'Waarvoor wil je een melding?', 'push.a_follow': 'Nieuwe volger', 'push.a_reply': 'Reactie of vermelding', 'push.a_like': 'Waardering (ster)', 'push.a_boost': 'Boost', 'push.a_dm': 'Privébericht', 'push.saved': 'Opgeslagen.', 'push.devices': 'Gekoppelde apparaten', 'push.device': 'Apparaat', 'push.since': 'sinds', 'push.remove': 'Verwijder', 'push.enable_failed': 'aanzetten mislukt', 'push.on_short': 'Word supporter',
-    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
+    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.g_available': 'beschikbaar', 'msg.g_away': 'afwezig tot {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'msg.reply_sent': 'Antwoord verstuurd.', 'msg.reply_failed': 'Het antwoord kon niet verstuurd worden.', 'msg.reply_empty': 'Een leeg antwoord versturen kan niet.', 'guardian.feed_title': 'Van je wards', 'guardian.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian.follow_title': 'Volgverzoeken', 'guardian.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian.wave': '👋 Zwaai', 'guardian.waved': '👋 verstuurd', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.play_propose': 'Afspelen voorstellen', 'guardian.play_on': 'Afspelen: aan', 'guardian.play_off': 'Afspelen: uit', 'guardian.gated_title': 'Instelling voorgesteld', 'guardian.gated_line_on': '{who} wil linkvoorbeelden AANzetten voor {ward}.', 'guardian.gated_line_off': '{who} wil linkvoorbeelden UITzetten voor {ward}.', 'guardian.gated_agree': 'Eens', 'guardian.gated_disagree': 'Oneens', 'guardian.avail_available': 'Beschikbaar', 'guardian.avail_away': 'Afwezig tot {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians van dit kind', 'guardian.panel_guards_remote': 'Dit kind woont op een andere server; de beschikbaarheid wordt daar bijgehouden.', 'guardian.lapse_propose': 'Voorstel: loslaten bij afwezigheid', 'guardian.lapse_line': '{who} antwoordt niet meer als guardian van {ward}.', 'guardian.lapse_tally': '{n} van {need} akkoord; sluit {date}; het venster loopt altijd vol.', 'guardian.lapse_note': 'Elk teken van leven van hen annuleert dit meteen. Niets hier is straf.', 'guardian.lapse_agree': 'Eens', 'guardian.lapse_disagree': 'Oneens', 'guardian.voted': 'Je hebt gestemd', 'guardian.away_title': 'Even afwezig', 'guardian.away_sub': 'Meld je wards dat je er even niet bent. Besluiten wachten niet op je, en een enkel antwoord brengt je meteen terug.', 'guardian.away_week': 'Een week', 'guardian.away_month': 'Een maand', 'guardian.away_done': 'Je wards weten dat je afwezig bent tot {date}.', 'guardian.away_msg': 'Ik ben afwezig als je guardian tot {date}. Je andere guardians zijn er voor je.', 'guardian.release_title': '{who} loslaten?', 'guardian.release_effect': 'Je stopt als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpvragen meer van ze, en je beoordeelt geen volgverzoeken meer voor ze. Terugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.release_local': 'Hun server en de andere guardians krijgen dit door, dus daarna sta jij ook bij hen niet meer als guardian.', 'guardian.release_step_down': 'Zij houden hun andere guardians, dus zij blijven een ward.', 'guardian.release_last': 'Je bent hun laatste guardian. Dat is emancipatie, en volgens FEP-633c 3.4 is dat niet aan een guardian alleen: daar horen drie instemmende volwassenen bij, of een meerderheid met twee getuigen. Deze knop kan dat dus niet: je blijft hun guardian tot dat geregeld is.', 'guardian.release_unknown': 'We konden hun server niet bereiken, dus we weten niet of jij hun laatste guardian bent.', 'guardian.release_yes': 'Ja, loslaten', 'guardian.release_no': 'Nee, toch niet', 'guardian.settings_title': 'Instellingen', 'guardian.panel_open': 'Bekijken', 'guardian.panel_close': 'Sluiten', 'guardian.panel_help': 'Hulpvragen van dit kind', 'guardian.panel_help_empty': 'Nog geen hulpvragen.', 'guardian.panel_follow': 'Volgverzoeken', 'guardian.panel_follow_empty': 'Geen openstaande volgverzoeken.', 'guardian.panel_posts': 'Recente berichten', 'guardian.panel_posts_empty': 'Nog niets te zien.', 'guardian.panel_actions': 'Acties', 'guardian.badge_help': 'hulpvragen', 'guardian.badge_follow': 'volgverzoeken', 'guardian.badge_follow_one': 'volgverzoek', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.prop_line': 'Voorstel {what} {value}: {status}', 'guardian.prop_embeds': 'linkvoorbeelden', 'guardian.prop_play': 'afspelen', 'guardian.prop_on': 'aan', 'guardian.prop_off': 'uit', 'guardian.prop_st_open': 'wacht op de andere guardians', 'guardian.prop_st_accepted': 'aangenomen', 'guardian.prop_st_rejected': 'afgewezen', 'guardian.prop_st_expired': 'verlopen zonder genoeg stemmen', 'guardian.panel_guards_far': 'Beschikbaarheid wordt op hun server bijgehouden.', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_left_t': 'Een guardian is gestopt', 'push.n_guard_left_b': '{who} is niet langer je guardian', 'push.n_guard_cogleft_t': 'Mede-guardian gestopt', 'push.n_guard_cogleft_b': '{who} heeft de guardianship beeindigd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'guardian.help_archive': '{n} afgehandeld', 'guardian.help_archive_hide': 'verbergen', 'guardian.help_pick': 'Ik kijk hiernaar', 'guardian.help_close': 'Markeer als afgehandeld', 'guardian.help_picked_by': '{who} kijkt hiernaar', 'guardian.help_handled_by': 'Afgehandeld door {who}', 'guardian.help_handled_note': 'Dit blijft staan. Leeft de vraag nog, dan stelt het kind hem opnieuw.', 'guardian.help_close_ask': 'Weet je het zeker? Dit is niet terug te draaien. Speelt het toch nog, dan vraagt het kind opnieuw om hulp.', 'guardian.help_close_yes': 'Ja, afgehandeld', 'guardian.help_just_now': 'zojuist', 'guardian.help_hours': '{n} uur geleden', 'guardian.help_days': '{n} dagen geleden', 'guardian.gate_externalEmbeds': 'Linkvoorbeelden', 'guardian.gate_externalPlayback': 'Afspelen in de app', 'guardian.gate_follows': 'Volgverzoeken', 'guardian.gate_kind_setting': 'stand', 'guardian.gate_kind_perRequest': 'per verzoek', 'guardian.gate_kind_handover': 'draagt gezag over', 'guardian.gate_unknown': 'onbekend', 'guardian.gate_threshold': '{need} van {of} guardians', 'guardian.gate_threshold_unknown': 'drempel onbekend (ander domein)', 'guardian.gate_irreversible': 'niet terug te draaien', 'guardian.gate_waiting': '{n} wacht op jullie', 'guardian.gate_blocked': 'kan pas als {what} aanstaat', 'guardian.gate_propose': 'Voorstellen', 'push.n_gate_ask_t': 'Je antwoord is nodig', 'push.n_gate_ask_b': 'Voorstel voor {who}: {wat} {stand}', 'push.n_gate_done_t': 'Besluit genomen', 'push.n_gate_done_b': '{wat} {stand} voor {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
     'apaid.t': 'Betaalde posts', 'apaid.intro': 'Koppel je eigen Patreon-campagne. Supporters ontgrendelen betaalde posts met een passkey, zonder account en zonder cookie. Wij bewaren geen namen of e-mailadressen van supporters, alleen het versleutelde token van jouw campagne.', 'apaid.saved': 'Opgeslagen.', 'apaid.nokey': 'Let op: de encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?). Zonder sleutel kunnen secrets niet veilig worden opgeslagen.', 'apaid.status': 'Status:', 'apaid.connected': 'verbonden', 'apaid.campaign': 'campagne', 'apaid.configured': 'ingesteld, nog niet verbonden (vul een token in)', 'apaid.notyet': 'nog niet ingesteld', 'apaid.redirect_h': 'Zet deze redirect-URI in je Patreon-client', 'apaid.redirect_p': 'Bij je Patreon API-client, onder Redirect URIs, moet exact deze regel staan. Klopt hij niet, dan geeft Patreon een foutmelding in plaats van je supporters terug te sturen.', 'apaid.copy': 'Kopieer', 'apaid.copied': 'Gekopieerd', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leeg laten = huidige waarde behouden.', 'apaid.campaign_id': 'Campagne-id', 'apaid.public_page': 'Openbare Patreon-pagina', 'apaid.public_help': 'De link waar bezoekers supporter kunnen worden. Getoond als "Word supporter" wanneer iemand nog niet doneert.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'De access + refresh token krijg je op je Patreon API-clientpagina. Wij versleutelen ze en verversen automatisch.', 'apaid.min_eur': 'Standaard-steunbedrag voor een betaalde post (euro)', 'apaid.save': 'Opslaan', 'apaid.disconnect': 'Koppeling verwijderen', 'apaid.disconnect_confirm': 'Patreon-koppeling verwijderen?', 'apaid.unchanged': 'blijft ongewijzigd',
     'pgate.h': 'Voor supporters', 'pgate.sub': 'Deze post is voor supporters van deze site. Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.sub_cents': 'Deze post is voor supporters van deze site (vanaf €{eur} per maand op Patreon). Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.join': 'Word supporter op Patreon', 'pgate.unlock_have': 'Al supporter? Ontgrendelen', 'pgate.unlock': 'Ontgrendelen met Patreon', 'pgate.join_short': 'Word supporter', 'pgate.confirm': 'Bevestig met je passkey…', 'pgate.failed': 'Ontgrendelen mislukt. Probeer opnieuw.', 'pgate.error': 'Er ging iets mis. Probeer opnieuw.',
@@ -1017,5 +1017,5 @@
     'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin',
     'push.t': 'Notifications', 'push.intro': 'Get a notification on this device for new followers, replies and messages, even when the site is closed. Encrypted all the way to your browser; we send as little content as possible.', 'push.unavailable': 'Push is unavailable on this server (the key could not be created or the dependency is missing).', 'push.unsupported': 'This browser does not support push notifications.', 'push.ios_hint': 'On iPhone/iPad this only works when the site is on your home screen: share button, then "Add to Home Screen", and open it from there.', 'push.this_device': 'This device:', 'push.checking': 'checking…', 'push.state_on': 'notifications are on', 'push.state_off': 'notifications are off', 'push.state_denied': 'blocked in the browser settings', 'push.state_unknown': 'status unknown', 'push.state_unsupported': 'not supported', 'push.enable': 'Turn on for this device', 'push.disable': 'Turn off', 'push.test': 'Send a test notification', 'push.what': 'What do you want to be notified about?', 'push.a_follow': 'New follower', 'push.a_reply': 'Reply or mention', 'push.a_like': 'Like (star)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private message', 'push.saved': 'Saved.', 'push.devices': 'Linked devices', 'push.device': 'Device', 'push.since': 'since', 'push.remove': 'Remove', 'push.enable_failed': 'turning on failed',
-    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
+    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.g_available': 'available', 'msg.g_away': 'unavailable till {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'msg.reply_sent': 'Reply sent.', 'msg.reply_failed': 'The reply could not be sent.', 'msg.reply_empty': 'An empty reply cannot be sent.', 'guardian.feed_title': 'Your wards', 'guardian.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian.follow_title': 'Follow requests', 'guardian.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian.wave': '👋 Wave', 'guardian.waved': '👋 sent', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.play_propose': 'Propose in-app playback', 'guardian.play_on': 'Playback: on', 'guardian.play_off': 'Playback: off', 'guardian.gated_title': 'Setting proposed', 'guardian.gated_line_on': '{who} wants to turn link previews ON for {ward}.', 'guardian.gated_line_off': '{who} wants to turn link previews OFF for {ward}.', 'guardian.gated_agree': 'Agree', 'guardian.gated_disagree': 'Disagree', 'guardian.avail_available': 'Available', 'guardian.avail_away': 'Unavailable till {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians of this child', 'guardian.panel_guards_remote': 'This child lives on another server; availability is tracked there.', 'guardian.lapse_propose': 'Propose release in absentia', 'guardian.lapse_line': '{who} has stopped answering as a guardian of {ward}.', 'guardian.lapse_tally': '{n} of {need} agreed; closes {date}; the window always runs in full.', 'guardian.lapse_note': 'Any sign of life from them cancels this outright. Nothing here is punishment.', 'guardian.lapse_agree': 'Agree', 'guardian.lapse_disagree': 'Disagree', 'guardian.voted': 'You voted', 'guardian.away_title': 'Step away', 'guardian.away_sub': 'Tell your wards you are unavailable for a while. Decisions will not wait for you, and one answer brings you straight back.', 'guardian.away_week': 'A week', 'guardian.away_month': 'A month', 'guardian.away_done': 'Your wards know you are unavailable until {date}.', 'guardian.away_msg': 'I am unavailable as your guardian until {date}. Your other guardians are there for you.', 'guardian.release_title': 'Release {who}?', 'guardian.release_effect': 'You stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them. Coming back means a fresh offer that they accept.', 'guardian.release_local': 'Their server and the other guardians are told, so afterwards you are no longer listed as a guardian there either.', 'guardian.release_step_down': 'They keep their other guardians, so they stay a ward.', 'guardian.release_last': 'You are their last guardian. That is emancipation, and FEP-633c 3.4 is explicit that no single guardian decides it: it takes three consenting adults, or a majority plus two witnesses. So this button cannot do it: you stay their guardian until that is arranged.', 'guardian.release_unknown': 'We could not reach their server, so we do not know whether you are their last guardian.', 'guardian.release_yes': 'Yes, release', 'guardian.release_no': 'No, keep them', 'guardian.settings_title': 'Settings', 'guardian.panel_open': 'Open', 'guardian.panel_close': 'Close', 'guardian.panel_help': 'Calls for help from this child', 'guardian.panel_help_empty': 'No calls for help yet.', 'guardian.panel_follow': 'Follow requests', 'guardian.panel_follow_empty': 'No follow requests waiting.', 'guardian.panel_posts': 'Recent posts', 'guardian.panel_posts_empty': 'Nothing here yet.', 'guardian.panel_actions': 'Actions', 'guardian.badge_help': 'calls for help', 'guardian.badge_follow': 'follow requests', 'guardian.badge_follow_one': 'follow request', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.prop_line': 'Proposal {what} {value}: {status}', 'guardian.prop_embeds': 'link previews', 'guardian.prop_play': 'playback', 'guardian.prop_on': 'on', 'guardian.prop_off': 'off', 'guardian.prop_st_open': 'waiting for the other guardians', 'guardian.prop_st_accepted': 'accepted', 'guardian.prop_st_rejected': 'rejected', 'guardian.prop_st_expired': 'expired without enough votes', 'guardian.panel_guards_far': 'Availability is tracked on their server.', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_left_t': 'A guardian has stepped down', 'push.n_guard_left_b': '{who} is no longer your guardian', 'push.n_guard_cogleft_t': 'Co-guardian stepped down', 'push.n_guard_cogleft_b': '{who} has ended their guardianship', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'guardian.help_archive': '{n} handled', 'guardian.help_archive_hide': 'hide', 'guardian.help_pick': 'I am on it', 'guardian.help_close': 'Mark as handled', 'guardian.help_picked_by': '{who} is looking into this', 'guardian.help_handled_by': 'Handled by {who}', 'guardian.help_handled_note': 'This stays. If it is still going on, the child asks again.', 'guardian.help_close_ask': 'Are you sure? This cannot be undone. If it is still going on, the child will ask for help again.', 'guardian.help_close_yes': 'Yes, handled', 'guardian.help_just_now': 'just now', 'guardian.help_hours': '{n}h ago', 'guardian.help_days': '{n}d ago', 'guardian.gate_externalEmbeds': 'Link previews', 'guardian.gate_externalPlayback': 'In-app playback', 'guardian.gate_follows': 'Follow requests', 'guardian.gate_kind_setting': 'setting', 'guardian.gate_kind_perRequest': 'per request', 'guardian.gate_kind_handover': 'hands over authority', 'guardian.gate_unknown': 'unknown', 'guardian.gate_threshold': '{need} of {of} guardians', 'guardian.gate_threshold_unknown': 'threshold unknown (other domain)', 'guardian.gate_irreversible': 'cannot be undone', 'guardian.gate_waiting': '{n} waiting on you', 'guardian.gate_blocked': 'needs {what} on first', 'guardian.gate_propose': 'Propose', 'push.n_gate_ask_t': 'Your answer is needed', 'push.n_gate_ask_b': 'Proposal for {who}: {wat} {stand}', 'push.n_gate_done_t': 'Decision reached', 'push.n_gate_done_b': '{wat} {stand} for {who}: {uitkomst}', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
     'apaid.t': 'Paid posts', 'apaid.intro': 'Connect your own Patreon campaign. Supporters unlock paid posts with a passkey, no account and no cookie. We store no supporter names or email addresses, only the encrypted token of your campaign.', 'apaid.saved': 'Saved.', 'apaid.nokey': 'Note: the encryption key could not be created or read (write permissions on the storage directory?). Without a key, secrets cannot be stored safely.', 'apaid.status': 'Status:', 'apaid.connected': 'connected', 'apaid.campaign': 'campaign', 'apaid.configured': 'configured, not connected yet (enter a token)', 'apaid.notyet': 'not configured yet', 'apaid.redirect_h': 'Put this redirect URI in your Patreon client', 'apaid.redirect_p': 'In your Patreon API client, under Redirect URIs, exactly this line must be present. If it does not match, Patreon shows an error instead of sending your supporters back.', 'apaid.copy': 'Copy', 'apaid.copied': 'Copied', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leave empty = keep the current value.', 'apaid.campaign_id': 'Campaign id', 'apaid.public_page': 'Public Patreon page', 'apaid.public_help': 'The link where visitors can become a supporter. Shown as "Become a supporter" when someone does not pledge yet.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'You get the access + refresh token on your Patreon API client page. We encrypt them and refresh automatically.', 'apaid.min_eur': 'Default support amount for a paid post (euro)', 'apaid.save': 'Save', 'apaid.disconnect': 'Remove connection', 'apaid.disconnect_confirm': 'Remove the Patreon connection?', 'apaid.unchanged': 'stays unchanged',
     'pgate.h': 'For supporters', 'pgate.sub': 'This post is for supporters of this site. Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.sub_cents': 'This post is for supporters of this site (from €{eur} per month on Patreon). Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.join': 'Become a supporter on Patreon', 'pgate.unlock_have': 'Already a supporter? Unlock', 'pgate.unlock': 'Unlock with Patreon', 'pgate.join_short': 'Become a supporter', 'pgate.confirm': 'Confirm with your passkey…', 'pgate.failed': 'Unlocking failed. Try again.', 'pgate.error': 'Something went wrong. Try again.',
@@ -1961,5 +1961,5 @@
     'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung',
     'push.t': 'Benachrichtigungen', 'push.intro': 'Erhalte auf diesem Gerät eine Meldung bei neuen Followern, Antworten und Nachrichten, auch wenn die Seite geschlossen ist. Verschlüsselt bis in deinen Browser; wir senden so wenig Inhalt wie möglich mit.', 'push.unavailable': 'Push ist auf diesem Server nicht verfügbar (Schlüssel konnte nicht erstellt werden oder die Abhängigkeit fehlt).', 'push.unsupported': 'Dieser Browser unterstützt keine Push-Benachrichtigungen.', 'push.ios_hint': 'Auf iPhone/iPad funktioniert das nur, wenn die Seite auf deinem Home-Bildschirm liegt: Teilen-Knopf, dann "Zum Home-Bildschirm", und öffne sie danach von dort.', 'push.this_device': 'Dieses Gerät:', 'push.checking': 'prüfen…', 'push.state_on': 'Benachrichtigungen sind an', 'push.state_off': 'Benachrichtigungen sind aus', 'push.state_denied': 'in den Browser-Einstellungen blockiert', 'push.state_unknown': 'Status unbekannt', 'push.state_unsupported': 'nicht unterstützt', 'push.enable': 'Auf diesem Gerät einschalten', 'push.disable': 'Ausschalten', 'push.test': 'Testmeldung senden', 'push.what': 'Wofür möchtest du eine Meldung?', 'push.a_follow': 'Neuer Follower', 'push.a_reply': 'Antwort oder Erwähnung', 'push.a_like': 'Like (Stern)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private Nachricht', 'push.saved': 'Gespeichert.', 'push.devices': 'Verbundene Geräte', 'push.device': 'Gerät', 'push.since': 'seit', 'push.remove': 'Entfernen', 'push.enable_failed': 'Einschalten fehlgeschlagen',
-    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
+    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.g_available': 'verfuegbar', 'msg.g_away': 'abwesend bis {date}', 'msg.g_offline': 'offline', 'msg.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'msg.reply_sent': 'Antwort gesendet.', 'msg.reply_failed': 'Die Antwort konnte nicht gesendet werden.', 'msg.reply_empty': 'Eine leere Antwort kann nicht gesendet werden.', 'guardian.feed_title': 'Deine Wards', 'guardian.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian.follow_title': 'Follow-Anfragen', 'guardian.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian.wave': '👋 Winken', 'guardian.waved': '👋 gesendet', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.play_propose': 'Abspielen vorschlagen', 'guardian.play_on': 'Abspielen: an', 'guardian.play_off': 'Abspielen: aus', 'guardian.gated_title': 'Einstellung vorgeschlagen', 'guardian.gated_line_on': '{who} moechte Link-Vorschauen fuer {ward} EINschalten.', 'guardian.gated_line_off': '{who} moechte Link-Vorschauen fuer {ward} AUSschalten.', 'guardian.gated_agree': 'Einverstanden', 'guardian.gated_disagree': 'Nicht einverstanden', 'guardian.avail_available': 'Verfuegbar', 'guardian.avail_away': 'Abwesend bis {date}', 'guardian.avail_dormant': 'Offline', 'guardian.panel_guards': 'Guardians dieses Kindes', 'guardian.panel_guards_remote': 'Dieses Kind lebt auf einem anderen Server; die Verfuegbarkeit wird dort gefuehrt.', 'guardian.lapse_propose': 'Vorschlag: Freigabe in Abwesenheit', 'guardian.lapse_line': '{who} antwortet nicht mehr als Guardian von {ward}.', 'guardian.lapse_tally': '{n} von {need} einverstanden; schliesst {date}; das Fenster laeuft immer voll.', 'guardian.lapse_note': 'Jedes Lebenszeichen von ihnen bricht dies sofort ab. Nichts hier ist Strafe.', 'guardian.lapse_agree': 'Einverstanden', 'guardian.lapse_disagree': 'Nicht einverstanden', 'guardian.voted': 'Du hast abgestimmt', 'guardian.away_title': 'Kurz abwesend', 'guardian.away_sub': 'Sag deinen Wards, dass du eine Weile nicht da bist. Entscheidungen warten nicht auf dich, und eine einzige Antwort bringt dich sofort zurueck.', 'guardian.away_week': 'Eine Woche', 'guardian.away_month': 'Ein Monat', 'guardian.away_done': 'Deine Wards wissen, dass du bis {date} abwesend bist.', 'guardian.away_msg': 'Ich bin als dein Guardian bis {date} abwesend. Deine anderen Guardians sind fuer dich da.', 'guardian.release_title': '{who} loslassen?', 'guardian.release_effect': 'Du bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Folgeanfragen fuer sie. Zurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.release_local': 'Ihr Server und die anderen Guardians werden benachrichtigt, danach stehst du auch dort nicht mehr als Guardian.', 'guardian.release_step_down': 'Sie behalten ihre anderen Guardians und bleiben also Ward.', 'guardian.release_last': 'Du bist ihr letzter Guardian. Das ist Emanzipation, und FEP-633c 3.4 sagt ausdruecklich, dass darueber kein einzelner Guardian entscheidet: dafuer braucht es drei zustimmende Erwachsene oder eine Mehrheit plus zwei Zeugen. Dieser Knopf kann das also nicht: du bleibst ihr Guardian, bis das geregelt ist.', 'guardian.release_unknown': 'Wir konnten ihren Server nicht erreichen und wissen daher nicht, ob du ihr letzter Guardian bist.', 'guardian.release_yes': 'Ja, loslassen', 'guardian.release_no': 'Nein, doch nicht', 'guardian.settings_title': 'Einstellungen', 'guardian.panel_open': 'Ansehen', 'guardian.panel_close': 'Schliessen', 'guardian.panel_help': 'Hilferufe dieses Kindes', 'guardian.panel_help_empty': 'Noch keine Hilferufe.', 'guardian.panel_follow': 'Folgeanfragen', 'guardian.panel_follow_empty': 'Keine offenen Folgeanfragen.', 'guardian.panel_posts': 'Neueste Beitraege', 'guardian.panel_posts_empty': 'Noch nichts zu sehen.', 'guardian.panel_actions': 'Aktionen', 'guardian.badge_help': 'Hilferufe', 'guardian.badge_follow': 'Folgeanfragen', 'guardian.badge_follow_one': 'Folgeanfrage', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.prop_line': 'Vorschlag {what} {value}: {status}', 'guardian.prop_embeds': 'Link-Vorschauen', 'guardian.prop_play': 'Abspielen', 'guardian.prop_on': 'an', 'guardian.prop_off': 'aus', 'guardian.prop_st_open': 'wartet auf die anderen Guardians', 'guardian.prop_st_accepted': 'angenommen', 'guardian.prop_st_rejected': 'abgelehnt', 'guardian.prop_st_expired': 'abgelaufen ohne genug Stimmen', 'guardian.panel_guards_far': 'Verfuegbarkeit wird auf ihrem Server gefuehrt.', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_left_t': 'Ein Guardian ist zurueckgetreten', 'push.n_guard_left_b': '{who} ist nicht mehr dein Guardian', 'push.n_guard_cogleft_t': 'Mit-Guardian zurueckgetreten', 'push.n_guard_cogleft_b': '{who} hat die Guardianship beendet', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'guardian.help_archive': '{n} erledigt', 'guardian.help_archive_hide': 'ausblenden', 'guardian.help_pick': 'Ich kuemmere mich', 'guardian.help_close': 'Als erledigt markieren', 'guardian.help_picked_by': '{who} kuemmert sich darum', 'guardian.help_handled_by': 'Erledigt von {who}', 'guardian.help_handled_note': 'Das bleibt stehen. Geht es noch weiter, fragt das Kind erneut.', 'guardian.help_close_ask': 'Sicher? Das laesst sich nicht rueckgaengig machen. Geht es noch weiter, bittet das Kind erneut um Hilfe.', 'guardian.help_close_yes': 'Ja, erledigt', 'guardian.help_just_now': 'gerade eben', 'guardian.help_hours': 'vor {n} Std.', 'guardian.help_days': 'vor {n} Tagen', 'guardian.gate_externalEmbeds': 'Link-Vorschauen', 'guardian.gate_externalPlayback': 'Abspielen in der App', 'guardian.gate_follows': 'Folgeanfragen', 'guardian.gate_kind_setting': 'Einstellung', 'guardian.gate_kind_perRequest': 'pro Anfrage', 'guardian.gate_kind_handover': 'gibt Zustaendigkeit ab', 'guardian.gate_unknown': 'unbekannt', 'guardian.gate_threshold': '{need} von {of} Guardians', 'guardian.gate_threshold_unknown': 'Schwelle unbekannt (andere Domain)', 'guardian.gate_irreversible': 'nicht rueckgaengig', 'guardian.gate_waiting': '{n} wartet auf euch', 'guardian.gate_blocked': 'geht erst, wenn {what} an ist', 'guardian.gate_propose': 'Vorschlagen', 'push.n_gate_ask_t': 'Deine Antwort wird gebraucht', 'push.n_gate_ask_b': 'Vorschlag fuer {who}: {wat} {stand}', 'push.n_gate_done_t': 'Entscheidung gefallen', 'push.n_gate_done_b': '{wat} {stand} fuer {who}: {uitkomst}', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
     'apaid.t': 'Bezahlte Beiträge', 'apaid.intro': 'Verbinde deine eigene Patreon-Kampagne. Unterstützer entsperren bezahlte Beiträge mit einem Passkey, ohne Konto und ohne Cookie. Wir speichern keine Namen oder E-Mail-Adressen von Unterstützern, nur das verschlüsselte Token deiner Kampagne.', 'apaid.saved': 'Gespeichert.', 'apaid.nokey': 'Achtung: der Verschlüsselungsschlüssel konnte nicht erstellt oder gelesen werden (Schreibrechte auf dem Speicherordner?). Ohne Schlüssel können Secrets nicht sicher gespeichert werden.', 'apaid.status': 'Status:', 'apaid.connected': 'verbunden', 'apaid.campaign': 'Kampagne', 'apaid.configured': 'eingerichtet, noch nicht verbunden (Token eintragen)', 'apaid.notyet': 'noch nicht eingerichtet', 'apaid.redirect_h': 'Trage diese Redirect-URI in deinen Patreon-Client ein', 'apaid.redirect_p': 'In deinem Patreon-API-Client muss unter Redirect URIs genau diese Zeile stehen. Stimmt sie nicht, zeigt Patreon eine Fehlermeldung statt deine Unterstützer zurückzuschicken.', 'apaid.copy': 'Kopieren', 'apaid.copied': 'Kopiert', 'apaid.client_id': 'Patreon Client-ID', 'apaid.client_secret': 'Patreon Client-Secret', 'apaid.keep': 'Leer lassen = aktuellen Wert behalten.', 'apaid.campaign_id': 'Kampagnen-ID', 'apaid.public_page': 'Öffentliche Patreon-Seite', 'apaid.public_help': 'Der Link, unter dem Besucher Unterstützer werden können. Wird als "Unterstützer werden" gezeigt, wenn jemand noch nicht spendet.', 'apaid.access': 'Creator Access-Token', 'apaid.refresh': 'Creator Refresh-Token', 'apaid.token_help': 'Access- und Refresh-Token bekommst du auf deiner Patreon-API-Client-Seite. Wir verschlüsseln sie und erneuern automatisch.', 'apaid.min_eur': 'Standard-Unterstützungsbetrag für einen bezahlten Beitrag (Euro)', 'apaid.save': 'Speichern', 'apaid.disconnect': 'Verbindung entfernen', 'apaid.disconnect_confirm': 'Patreon-Verbindung entfernen?', 'apaid.unchanged': 'bleibt unverändert',
     'pgate.h': 'Für Unterstützer', 'pgate.sub': 'Dieser Beitrag ist für Unterstützer dieser Seite. Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.sub_cents': 'Dieser Beitrag ist für Unterstützer dieser Seite (ab €{eur} pro Monat auf Patreon). Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.join': 'Unterstützer werden auf Patreon', 'pgate.unlock_have': 'Schon Unterstützer? Entsperren', 'pgate.unlock': 'Mit Patreon entsperren', 'pgate.join_short': 'Unterstützer werden', 'pgate.confirm': 'Bestätige mit deinem Passkey…', 'pgate.failed': 'Entsperren fehlgeschlagen. Versuch es erneut.', 'pgate.error': 'Etwas ist schiefgegangen. Versuch es erneut.',
Index: src/views/pages/admin-audio.ejs
===================================================================
--- src/views/pages/admin-audio.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-audio.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -573,436 +573,4 @@
 <%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
 
-<script>
-(function() {
-
-  // ── Live filename display for custom file inputs ──────────────
-  document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
-    const nameEl = input.parentElement.querySelector('.ax-file-name');
-    if (!nameEl) return;
-    input.addEventListener('change', () => {
-      if (input.files && input.files[0]) {
-        nameEl.textContent = input.files[0].name;
-        nameEl.removeAttribute('data-empty');
-      } else {
-        nameEl.textContent = '<%= t('aaud.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('aaud.pause') %>' : '<%= t('aaud.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('aaud.st_queued') %>',
-      uploading:   '<%= t('aaud.st_uploading') %>',
-      transcoding: '<%= t('aaud.st_transcoding') %>',
-      done:        '✓ <%= t('aaud.st_done') %>',
-      error:       '✗ <%= t('aaud.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('aaud.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 => {
-    window.addEventListener(ev, e => {
-      // Allow drops INSIDE the dropzone — its own listener handles those.
-      if (dropzone.contains(e.target)) return;
-      e.preventDefault();
-    });
-  });
-
-  ['dragenter', 'dragover'].forEach(ev => {
-    dropzone.addEventListener(ev, e => {
-      e.preventDefault();
-      dropzone.classList.add('is-dragover');
-    });
-  });
-  ['dragleave', 'drop'].forEach(ev => {
-    dropzone.addEventListener(ev, e => {
-      e.preventDefault();
-      dropzone.classList.remove('is-dragover');
-    });
-  });
-  dropzone.addEventListener('drop', e => {
-    if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
-  });
-  fileInput.addEventListener('change', () => {
-    addFiles(fileInput.files);
-    // Reset so the same file can be picked again later if user wants
-    fileInput.value = '';
-  });
-
-  // ── Clear queue button ───────────────────────────────────────
-  clearBtn.addEventListener('click', () => {
-    // Only remove items that aren't currently uploading (anything queued
-    // or already finished). An in-progress upload finishes, then its row
-    // would also disappear once we re-render — but we keep it simple and
-    // just refuse to clear during an active run.
-    if (startBtn.disabled) return;
-    queue.length = 0;
-    queueEl.innerHTML = '';
-    queueEl.hidden = true;
-    actionsEl.hidden = true;
-  });
-
-  // ── Sequential upload loop ───────────────────────────────────
-  startBtn.addEventListener('click', async () => {
-    if (startBtn.disabled) return;
-    startBtn.disabled = true;
-    clearBtn.disabled = true;
-    dropzone.style.pointerEvents = 'none';
-    dropzone.style.opacity = '0.5';
-
-    const sharedArtist = artistInput.value.trim();
-    const sharedAlbum  = albumInput.value.trim();
-    const sharedCover  = coverInput.files && coverInput.files[0];
-
-    // Process queued items one at a time. We iterate via index so that
-    // if more files get dropped during the run they ALSO get processed
-    // (queue.push above mutates the same array we're iterating).
-    for (let i = 0; i < queue.length; i++) {
-      const item = queue[i];
-      if (item.status !== 'queued') continue;
-      try {
-        await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
-      } catch (err) {
-        console.error('upload failed for', item.file.name, err);
-        setItemStatus(item, 'error', '✗ ' + (err.message || '<%= t('aaud.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('aaud.err_unexpected') %> (' + res.status + ')'); }
-
-      if (!res.ok || !data.ok) {
-        throw new Error(data.error || ('HTTP ' + res.status));
-      }
-
-      setItemStatus(item, 'done', '✓ ' + (data.title || '<%= t('aaud.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('aaud.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('aaud.new_track') %>' }),
-        });
-        const j = await r.json();
-        if (!r.ok || !j.ok) throw new Error(j.error || '<%= t('aaud.create_failed') %>');
-        if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
-        window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
-      } catch (err) {
-        alert('<%= t('aaud.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('aaud.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('aaud.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('aaud.dl_on') %>'
-          : '<%= t('aaud.dl_off') %>';
-      } catch (e) {
-        alert('<%= t('aaud.change_failed') %>: ' + (e.message || e));
-      } finally {
-        btn.disabled = false;
-      }
-    });
-  });
-})();
-</script>
+<%# Het script staat in assets/js/mod/admin-audio.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
+<%- include('../partials/page-data', { pageData: { change_failed: t('aaud.change_failed'), copied: t('aaud.copied'), create_failed: t('aaud.create_failed'), dl_off: t('aaud.dl_off'), dl_on: t('aaud.dl_on'), editor_not_loaded: t('aaud.editor_not_loaded'), err_unexpected: t('aaud.err_unexpected'), failed: t('aaud.failed'), new_track: t('aaud.new_track'), no_file: t('aaud.no_file'), pause: t('aaud.pause'), play: t('aaud.play'), st_done: t('aaud.st_done'), st_error: t('aaud.st_error'), st_queued: t('aaud.st_queued'), st_transcoding: t('aaud.st_transcoding'), st_uploading: t('aaud.st_uploading'), untitled: t('aaud.untitled') } }) %>
Index: src/views/pages/admin-epk.ejs
===================================================================
--- src/views/pages/admin-epk.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-epk.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -90,16 +90,3 @@
   .admin-page .epk-pick-item input:disabled ~ .epk-pick-title { opacity: 0.45; }
 </style>
-<script>
-(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();
-})();
-</script>
+<%# Het script van deze pagina staat in assets/js/mod/admin-epk.js (shaer-bqr). %>
Index: src/views/pages/admin-help.ejs
===================================================================
--- src/views/pages/admin-help.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-help.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -145,25 +145,3 @@
   .hb-empty { color: var(--ink-muted, var(--ink-soft)); padding: 1rem; text-align: center; }
 </style>
-<script>
-(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);
-})();
-</script>
+<%# Het script van deze pagina staat in assets/js/mod/admin-help.js (shaer-bqr). %>
Index: src/views/pages/admin-media.ejs
===================================================================
--- src/views/pages/admin-media.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-media.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -41,34 +41,4 @@
 </div>
 
-<script>
-(function () {
-  if (window.__mediaWired) return; window.__mediaWired = true;
-  var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') }) %>;
-  document.addEventListener('click', function (e) {
-    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'); });
-    }
-  });
-})();
-</script>
+<%# Het script staat in assets/js/mod/admin-media.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
+<%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.media_del_confirm'), cleanC: t('admin.media_cleanup_confirm') } }) %>
Index: src/views/pages/admin-paid.ejs
===================================================================
--- src/views/pages/admin-paid.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-paid.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -72,16 +72,3 @@
 </div>
 
-<script>
-(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) {} }
-  });
-})();
-</script>
+<%# Het script van deze pagina staat in assets/js/mod/admin-paid.js (shaer-bqr). %>
Index: src/views/pages/admin-playlists.ejs
===================================================================
--- src/views/pages/admin-playlists.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-playlists.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -222,71 +222,4 @@
 </style>
 
-<script>
-(function() {
-  const csrf = '<%= _csrf %>';
-
-  document.getElementById('pl-new-btn')?.addEventListener('click', () => {
-    if (typeof window.openPlaylistEditor === 'function') {
-      window.openPlaylistEditor({ mode: 'create', onSaved: () => location.reload() });
-    }
-  });
-
-  // Click-to-copy on shortcodes
-  document.querySelectorAll('[data-copy]').forEach(el => {
-    el.addEventListener('click', async () => {
-      try {
-        await navigator.clipboard.writeText(el.dataset.copy);
-        el.classList.add('is-copied');
-        const original = el.textContent;
-        el.textContent = '✓ <%= t('apl.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('<%= t('apl.delete_confirm') %>'.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('<%= t('apl.delete_failed') %>: ' + (j.error || ''));
-      } catch (err) {
-        alert('<%= t('apl.delete_failed') %>: ' + err.message);
-      }
-    });
-  });
-
-  // P52 — deep-link from playlist embed (?edit=<id>) auto-opens the editor.
-  // openPlaylistEditor is defined synchronously by the included partial, so
-  // it's available by the time this IIFE runs.
-  (function deepLinkEdit() {
-    const params = new URLSearchParams(location.search);
-    const editId = params.get('edit');
-    if (!editId) return;
-    if (typeof window.openPlaylistEditor !== 'function') return;
-    // Strip the query param immediately so reload after save doesn't re-open.
-    history.replaceState({}, '', location.pathname);
-    window.openPlaylistEditor({
-      mode: 'edit',
-      id: editId,
-      onSaved: () => location.reload(),
-    });
-  })();
-})();
-</script>
+<%# Het script staat in assets/js/mod/admin-playlists.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
+<%- include('../partials/page-data', { pageData: { csrf: _csrf, copied: t('apl.copied'), delConfirm: t('apl.delete_confirm'), delFailed: t('apl.delete_failed') } }) %>
Index: src/views/pages/admin-push.ejs
===================================================================
--- src/views/pages/admin-push.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-push.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -58,106 +58,4 @@
   i18n: { on: t('push.state_on'), off: t('push.state_off'), denied: t('push.state_denied'), unknown: t('push.state_unknown'), unsupported: t('push.state_unsupported'), failed: t('push.enable_failed') },
 }).replace(/</g, '\\u003c') %></script>
-<script>
-(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(); });
-    });
-  });
-})();
-</script>
+<%# Het script van deze pagina staat in assets/js/mod/admin-push.js (shaer-bqr). %>
 <% } %>
Index: src/views/pages/admin-settings.ejs
===================================================================
--- src/views/pages/admin-settings.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-settings.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -27,16 +27,5 @@
       <div class="set-actions"><button type="submit" class="btn btn-primary"><%= t('aset.save') %></button></div>
     </form>
-    <script>
-    (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();
-        }
-      });
-    })();
-    </script>
+    <%# Het script van deze pagina staat in assets/js/mod/admin-settings.js (shaer-bqr). %>
   </section>
 
Index: src/views/pages/admin-site-edit.ejs
===================================================================
--- src/views/pages/admin-site-edit.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-site-edit.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -126,25 +126,5 @@
     <%# Live preview: selecting an accent / theme / palette re-themes this page
         instantly (reverts on reload; persists on save). %>
-    <script>
-    (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'); }
-        }
-      });
-    })();
-    </script>
+    <%# Het script van deze pagina staat in assets/js/mod/admin-site-edit.js (shaer-bqr). %>
 
     <fieldset>
@@ -275,104 +255,5 @@
 </div>
 
-<script>
-(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.
-    });
-  }
-})();
-</script>
+
 
 <style>
Index: src/views/pages/admin-videos.ejs
===================================================================
--- src/views/pages/admin-videos.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/admin-videos.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -40,26 +40,4 @@
 </div>
 
-<script>
-(function () {
-  if (window.__videosWired) return; window.__videosWired = true;
-  var T = <%- JSON.stringify({ copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') }) %>;
-  document.addEventListener('click', function (e) {
-    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 () {});
-    }
-  });
-})();
-</script>
+<%# Het script staat in assets/js/mod/admin-videos.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
+<%- include('../partials/page-data', { pageData: { copy: t('admin.media_copy'), delC: t('admin.videos_del_confirm') } }) %>
Index: src/views/pages/auth-register.ejs
===================================================================
--- src/views/pages/auth-register.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/auth-register.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -41,14 +41,3 @@
   .setup-handle-hint code { color: var(--accent); font-weight: 600; background: color-mix(in srgb, var(--accent) 12%, transparent); padding: .05rem .35rem; border-radius: 6px; white-space: nowrap; }
 </style>
-<script>
-(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();
-})();
-</script>
+<%# Het script van deze pagina staat in assets/js/mod/auth-register.js (shaer-bqr). %>
Index: src/views/pages/authorize-interaction.ejs
===================================================================
--- src/views/pages/authorize-interaction.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/authorize-interaction.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -12,12 +12,5 @@
       <p class="fedi-bm-help"><%= t('fedi.bm_help') %></p>
     </div>
-    <script>
-    (function () {
-      if (window.__fediBmWired) return; window.__fediBmWired = true;
-      var a = document.getElementById('fedi-bm-btn'); if (!a) return;
-      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); });
-    })();
-    </script>
+    <%# Het script van deze pagina staat in assets/js/mod/authorize-interaction.js (shaer-bqr). %>
     <% if (!manage.length) { %>
       <p class="auth-interact-note"><%= t('fedi.manage_empty') %></p>
@@ -56,19 +49,5 @@
         <% }); %>
       </ul>
-      <script>
-      (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(); }
-        });
-      })();
-      </script>
+      
     <% } %>
     <p class="auth-interact-note"><a href="/"><%= t('fedi.remote_back') %></a></p>
@@ -331,29 +310,3 @@
 </style>
 
-<script>
-/* 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; });
-  });
-})();
-</script>
+
Index: src/views/pages/download.ejs
===================================================================
--- src/views/pages/download.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/download.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -26,8 +26,5 @@
       <p class="dl-sub"><%= _ready_sub %></p>
       <p><a class="dl-go" href="<%= fileUrl %>"><%= _manual %></a></p>
-      <script>
-        // Auto-start de download (zelfde-origin attachment-link).
-        setTimeout(function(){ try { window.location.href = <%- JSON.stringify(fileUrl) %>; } catch(e){} }, 600);
-      </script>
+      <%# Het script staat in assets/js/mod/download.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
     <% } else { %>
       <h1 class="dl-h1"><%= _download_btn %> <%= tr.title %></h1>
@@ -57,2 +54,3 @@
   .dl-go { padding: 12px 20px; border-radius: 10px; border: none; background: var(--accent,#6b8f71); color: #fff; font-weight: 600; font-size: 15px; cursor: pointer; text-decoration: none; display: inline-block; }
 </style>
+<%- include('../partials/page-data', { pageData: { fileUrl: fileUrl } }) %>
Index: src/views/pages/messages.ejs
===================================================================
--- src/views/pages/messages.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/messages.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -2,6 +2,6 @@
   <%- include('../partials/fedi-tabs', { active: 'berichten' }) %>
   <h1 class="msg-title"><%= t('msg.title') %></h1>
-  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : success)) %></div><% } %>
-  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : error %></div><% } %>
+  <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success === 'guardian_accepted' ? t('msg.guard_accepted') : (success === 'guardian_rejected' ? t('msg.guard_rejected') : (success === 'wave_sent' ? t('msg.wave_sent') : (success === 'reply_sent' ? t('msg.reply_sent') : success))) %></div><% } %>
+  <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error === 'guardianship' ? t('msg.guard_failed') : (error === 'reply_empty' ? t('msg.reply_empty') : ((error === 'reply_failed' || error === 'reply_target') ? t('msg.reply_failed') : error)) %></div><% } %>
 
   <%# FEP-633c: a pending guardianship offer is a special message: the kid
@@ -25,10 +25,11 @@
 
   <div class="msg-filters" role="tablist" aria-label="<%= t('msg.title') %>">
+    <%# Vier chips, geen zes: Berichten, Gesprekken en Verzonden gingen op in
+        een enkele Gesprekken-view, waarin verzonden en ontvangen in dezelfde
+        draad staan. Activiteit en Moderatie blijven wat ze waren. %>
     <button type="button" class="msg-chip is-on" data-show="all"><%= t('msg.filter_all') %></button>
-    <button type="button" class="msg-chip" data-show="msgs"><%= t('msg.filter_msgs') %></button>
     <button type="button" class="msg-chip" data-show="conv"><%= t('msg.filter_conv') %></button>
     <button type="button" class="msg-chip" data-show="act"><%= t('msg.filter_act') %></button>
     <button type="button" class="msg-chip" data-show="mod"><%= t('msg.filter_mod') %></button>
-    <button type="button" class="msg-chip" data-show="sent"><%= t('msg.filter_sent') %></button>
   </div>
   <div class="msg-search">
@@ -59,62 +60,8 @@
 </div>
 
-<script>
-(function () {
-  if (window.__msgWired) return; window.__msgWired = true;
-
-  // Filtering: kind chip AND free-text search, combined in JS (search over the
-  // sender and the rendered message text). Empty search + "all" = show all.
-  var list = document.querySelector('.msg-list');
-  var noMatch = document.querySelector('.msg-nomatch');
-  var q = document.getElementById('msg-q');
-  var kind = 'all';
-  var items = [];
-  function indexItem(li) {
-    // Index once: sender + message body + linked post title + poll text.
-    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();
-  }
-  // Re-collect + index; called on load and after each "Load more" append so new
-  // items join the filter/search (and inherit the active chip via apply()).
-  function reindex() {
-    items = list ? Array.prototype.slice.call(list.querySelectorAll('.msg-item')) : [];
-    items.forEach(function (li) { if (!li._search) indexItem(li); });
-  }
-  reindex();
-  function apply() {
-    if (!list) return;
-    var term = (q && q.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';
-      if (ok) shown++;
-    });
-    if (noMatch) noMatch.hidden = shown !== 0;
-  }
-  document.addEventListener('click', function (e) {
-    var chip = 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();
-  });
-  if (q) q.addEventListener('input', apply);
-  // After a "Load more" append (htmx), index the new rows and re-apply the filter.
-  document.body.addEventListener('htmx:afterSettle', function (e) {
-    if (e.target && e.target.id === 'msg-list') { reindex(); apply(); }
-  });
-
-  var a = document.getElementById('fedi-bm-btn');
-  if (a) {
-    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); });
-  }
-})();
-</script>
+<%# Filteren, zoeken en het in-/uitklappen zitten in assets/js/mod/messages.js.
+    Inline script hier wordt door de CSP geweigerd zodra je deze pagina via een
+    link BINNEN de site opent -- zie shaer-0i6. De shell laadt de module op
+    body[data-js]; deze pagina vraagt erom met pageJs (routes/posts.js). %>
 
 <style>
@@ -138,4 +85,5 @@
 
   .msg-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .55rem; }
+
 
   .msg-item { display: flex; gap: .8rem; padding: .85rem .95rem; border-radius: 14px; align-items: flex-start;
@@ -148,4 +96,78 @@
     background: color-mix(in srgb, var(--accent, #06c) 4%, transparent); }
   .msg-sent { border-left: 3px solid color-mix(in srgb, var(--accent, #06c) 55%, transparent); }
+
+  /* ── Gesprekken ──────────────────────────────────────────────────────
+     NA .msg-item, en dat is geen smaak: .msg-item zet display:flex, en bij
+     gelijke specificiteit wint de laatste regel. Stond dit ervoor, dan kwam de
+     draadkop NAAST de bubbels te staan in plaats van erboven. */
+  .msg-thread { display: block; }
+  .msg-thread-head { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-bottom: .4rem; }
+  /* De tegenpartij is een labeltje bij het gesprek, geen kop erboven: een
+     kleine verzonken uitsparing, zodat de bubbels eronder de ruimte krijgen.
+     Het aantal zit in dezelfde uitsparing in plaats van ernaast. */
+  /* Ook de knop die het gesprek in- en uitklapt. Een button erft geen font en
+     geen kleur, dus die staan hier expliciet; zonder dat wordt het ineens
+     systeemblauw in 13px. */
+  .msg-thread-who { display: inline-flex; align-items: center; gap: .3rem; min-width: 0;
+    font: inherit; font-size: .78em; line-height: 1.5; font-weight: 500;
+    padding: .05rem .5rem; border-radius: 999px; border: 0; cursor: pointer;
+    color: color-mix(in srgb, var(--ink, #000) 62%, transparent);
+    background: color-mix(in srgb, var(--ink, #000) 5%, transparent);
+    box-shadow: inset 0 1px 2px color-mix(in srgb, var(--ink, #000) 9%, transparent);
+    max-width: 100%; overflow: hidden; white-space: nowrap;
+    /* Ruimer aanraakvlak dan de tekst: een chip van 18px hoog is op een telefoon
+       niet te raken. De uitsparing blijft klein, het doel wordt groter. */
+    min-height: 32px; }
+  .msg-thread-who:hover { background: color-mix(in srgb, var(--ink, #000) 9%, transparent); }
+  .msg-thread-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .msg-thread-chevron { width: 12px; height: 12px; flex: none; opacity: .55;
+    transition: transform .18s ease; }
+  /* Ingeklapt: de bubbels en het antwoordveld verdwijnen, de uitsparing met de
+     naam en het AANTAL blijft staan -- dat aantal is juist ingeklapt het meest
+     waard. */
+  .msg-thread.is-collapsed .msg-thread-msgs,
+  .msg-thread.is-collapsed .msg-thread-foot { display: none; }
+  /* Tijdens het zoeken wint de treffer van de dichtgeklapte stand. */
+  .msg-thread.is-collapsed.is-search-open .msg-thread-msgs { display: flex; }
+  .msg-thread.is-collapsed.is-search-open .msg-thread-foot { display: flex; }
+  .msg-thread.is-collapsed .msg-thread-chevron { transform: rotate(-90deg); }
+  .msg-thread.is-collapsed .msg-thread-head { margin-bottom: 0; }
+  @media (prefers-reduced-motion: reduce) { .msg-thread-chevron { transition: none; } }
+  .msg-thread-count { font-variant-numeric: tabular-nums; opacity: .75; flex: 0 0 auto;
+    padding-inline-start: .3rem; border-inline-start: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); }
+  .msg-thread-post { display: inline-flex; align-items: center; gap: .3rem; font-size: .85em;
+    margin-inline-start: auto; min-width: 0; max-width: 100%;
+    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .msg-thread-post svg { width: 13px; height: 13px; flex: none; }
+  .msg-thread-msgs { list-style: none; margin: 0; padding: 0 0 0 .7rem; display: flex; flex-direction: column; gap: .5rem;
+    border-inline-start: 2px solid color-mix(in srgb, var(--ink, #000) 10%, transparent); }
+  .msg-sub { display: flex; gap: .6rem; align-items: flex-start; min-width: 0; }
+  .msg-sub > .msg-body { min-width: 0; }
+  /* Jouw eigen bijdrage onderscheidt zich met een tint en de accentlijn die
+     .msg-sent al had -- niet met row-reverse: dat duwde de avatar buiten de
+     kaart zodra de regel smaller werd dan zijn inhoud. */
+  .msg-sub.msg-sent { border-radius: 10px; padding: .3rem .45rem;
+    background: color-mix(in srgb, var(--accent, #06c) 5%, transparent); }
+  /* Een lange naam of tijd mag de bubbel niet uit de kaart duwen. */
+  .msg-sub .msg-line { flex-wrap: wrap; }
+
+  /* De voet van een gesprek: antwoorden en zwaaien naast elkaar, ingeklapt.
+     Een open editor onder elk gesprek maakt de lijst weer onleesbaar -- precies
+     wat deze weergave moest oplossen. */
+  .msg-thread-foot { display: flex; align-items: flex-start; gap: .5rem; margin-top: .5rem; flex-wrap: wrap; }
+  .msg-reply { flex: 1 1 12rem; min-width: 0; }
+  .msg-reply > summary { cursor: pointer; font-size: .82rem; color: var(--ink-soft, #888); }
+  .msg-reply[open] > summary { margin-bottom: .4rem; }
+  .msg-reply-form { min-width: 0; }
+  .msg-wave button { line-height: 1.2; }
+
+  @media (max-width: 560px) {
+    /* Op een telefoon is 40px avatar per bubbel puur verlies: de naam staat er
+       al naast, en de inhoud heeft de breedte harder nodig. */
+    .msg-sub > .msg-av { display: none; }
+    .msg-thread-msgs { padding-inline-start: .55rem; }
+    .msg-thread-post { margin-inline-start: 0; }
+    .msg-item { padding: .7rem .6rem; gap: .6rem; }
+  }
 
   .msg-av { position: relative; flex: 0 0 40px; width: 40px; height: 40px; border-radius: 50%;
Index: src/views/pages/news.ejs
===================================================================
--- src/views/pages/news.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/news.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -41,42 +41,5 @@
     .tl-paste-go { background: var(--accent); color: var(--paper); }
   </style>
-  <script>
-  (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(); }
-    });
-  })();
-  </script>
+  <%# Het script staat in assets/js/mod/news.js; de servergegevens gaan via partials/page-data.ejs (shaer-bqr). %>
   <% if (typeof success !== 'undefined' && success) { %><div class="alert alert-success"><%= success %></div><% } %>
   <% if (typeof error !== 'undefined' && error) { %><div class="alert alert-error"><%= error %></div><% } %>
@@ -184,86 +147,3 @@
   .tl-act-block:hover { background: color-mix(in srgb, #d9534f 14%, transparent); border-color: color-mix(in srgb, #d9534f 50%, transparent); }
 </style>
-
-<script>
-(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. */
-(function(){
-  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 });
-  });
-})();
-
-/* 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; });
-  });
-})();
-</script>
-
-<script>
-// 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.
-(function(){
-  var RM = '<%= t("tl.read_more") %>', SL = '<%= t("tl.show_less") %>';
-  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);
-    }
-  });
-})();
-</script>
+<%- include('../partials/page-data', { pageData: { readMore: t('tl.read_more'), showLess: t('tl.show_less') } }) %>
Index: src/views/pages/paid-gate.ejs
===================================================================
--- src/views/pages/paid-gate.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/paid-gate.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -31,58 +31,5 @@
 
 <script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script>
-<script>
-(function () {
-  var base = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>";
-  var slug = "<%= pgSlug %>";
-  var hasPatron = <%= _hasPatron ? 'true' : 'false' %>;
-  var I = { join: "<%= t('pgate.join_short') %>", confirm: "<%= t('pgate.confirm') %>", failed: "<%= t('pgate.failed') %>", error: "<%= t('pgate.error') %>" };
-  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);
-      });
-  });
-})();
-</script>
+<%# Het script staat in assets/js/mod/paid-gate.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
 
 <style>
@@ -105,2 +52,3 @@
   .pg-status.is-err { color: #c0392b; }
 </style>
+<%- include('../partials/page-data', { pageData: { base: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : ''), slug: pgSlug, hasPatron: !!_hasPatron, i18n: { join: t('pgate.join_short'), confirm: t('pgate.confirm'), failed: t('pgate.failed'), error: t('pgate.error') } } }) %>
Index: src/views/pages/paid-passkey.ejs
===================================================================
--- src/views/pages/paid-passkey.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/paid-passkey.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -12,38 +12,5 @@
 
 <script src="/assets/vendor/simplewebauthn-browser.umd.min.js"></script>
-<script>
-(function () {
-  var options = <%- optionsJson %>;
-  var blob = "<%= regBlob %>";
-  var I = { unsupported: "<%= t('ppk.unsupported') %>", follow: "<%= t('ppk.follow') %>", done: "<%= t('ppk.done') %>", failed: "<%= t('ppk.failed') %>", cancelled: "<%= t('ppk.cancelled') %>", error: "<%= t('pgate.error') %>" };
-  var postUrl = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>/<%= postSlug %>";
-  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); });
-  });
-})();
-</script>
+<%# Het script staat in assets/js/mod/paid-passkey.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
 
 <style>
@@ -58,2 +25,3 @@
   .pk-status.is-err { color: #c0392b; }
 </style>
+<%- include('../partials/page-data', { pageData: { options: JSON.parse(optionsJson), blob: regBlob, i18n: { unsupported: t('ppk.unsupported'), follow: t('ppk.follow'), done: t('ppk.done'), failed: t('ppk.failed'), cancelled: t('ppk.cancelled'), error: t('pgate.error') }, postUrl: (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') + '/' + postSlug } }) %>
Index: src/views/pages/post-edit.ejs
===================================================================
--- src/views/pages/post-edit.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/post-edit.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -272,20 +272,7 @@
         <% } else { %>
           <p id="pe-fedi-audio-warn" style="font-size:12px;color:#c0392b;margin:2px 0 0 26px" hidden>⚠️ <%= t('pedit.fedi_audio_oneway') %></p>
-          <script>
-          (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; }); }
-          })();
-          </script>
+          <%# De scripts van deze pagina staan in assets/js/mod/post-edit.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
         <% } %>
-        <script>
-        (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; });
-          }
-        })();
-        </script>
+        
         <% // Poll (federates as an AS2 Question). Free feature. A poll with votes is frozen.
            var _poll = null; try { _poll = post.poll_json ? JSON.parse(post.poll_json) : null; } catch (e) { _poll = null; }
@@ -335,31 +322,5 @@
           </select>
         </div>
-        <script>
-        (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();
-        })();
-        </script>
+        
         <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %>
           <label class="pe-checkbox">
@@ -377,8 +338,5 @@
             </label>
           </div>
-          <script>
-            (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'; }); } })();
-          </script>
+          
           <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
           <label class="pe-checkbox" style="margin-top:8px">
@@ -392,62 +350,5 @@
             <div style="font-size:11.5px;opacity:.65;margin-top:4px"><%= t('pedit.schedule_hint') %></div>
           </div>
-          <script>
-            (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 = '<%= 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);
-                    }
-                  }
-                });
-              }
-            })();
-          </script>
+          
         <% } %>
       </div>
@@ -1281,921 +1182,5 @@
 </style>
 
-<script>
-(function() {
-
-  // ── Cover upload ────────────────────────────────────────────────
-  const coverField   = document.getElementById('cover-upload-field');
-  const coverTrigger = document.getElementById('cover-upload-trigger');
-  const coverUrl     = document.getElementById('cover-url-field');
-  const 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');
-        l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', '');
-        document.head.appendChild(l);
-      }
-      const s = document.createElement('script');
-      s.src = '/assets/vendor/cropper.min.js';
-      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="<%= t('imed.title') %>">' +
-          '<div class="imed-stage"><img alt=""></div>' +
-          '<div class="imed-tools">' +
-            '<button type="button" data-act="rl" title="<%= t('imed.rotate_left') %>">⟲</button>' +
-            '<button type="button" data-act="rr" title="<%= t('imed.rotate_right') %>">⟳</button>' +
-            '<button type="button" data-act="fh" title="<%= t('imed.flip_h') %>">⇆</button>' +
-            '<button type="button" data-act="fv" title="<%= t('imed.flip_v') %>">⇅</button>' +
-            '<button type="button" data-act="zi" title="<%= t('imed.zoom_in') %>">＋</button>' +
-            '<button type="button" data-act="zo" title="<%= t('imed.zoom_out') %>">－</button>' +
-            '<button type="button" data-act="reset" title="<%= t('imed.reset') %>">↺</button>' +
-          '</div>' +
-          '<div class="imed-actions">' +
-            '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary"><%= t('imed.cancel') %></button>' +
-            '<button type="button" data-act="apply" class="pe-btn pe-btn-primary"><%= t('imed.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 = '<%= t('pedit.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 ? '🎬 ' : '') + '<%= t('pedit.js_uploaded') %> ✓';
-        setTimeout(() => { coverStatus.textContent = ''; }, 2000);
-      } catch (e) {
-        coverStatus.classList.add('is-error');
-        coverStatus.textContent = '<%= t('pedit.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 '<%= t('pedit.chip_track') %> ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
-    }
-    if (kind === 'album')    return '<%= t('pedit.chip_album') %> ' + value;
-    if (kind === 'playlist') return '<%= t('pedit.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('<%= t('pedit.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);
-
-  // 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) 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 ? '<%= t('pedit.tb_done') %>' : '<%= t('pedit.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(); }
-  window.addEventListener('popstate', function () { if (isFs()) applyFs(false); });
-  if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
-  var fsDoneBtn = document.getElementById('pe-fs-done');
-  if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs);
-  document.addEventListener('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.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());
-  }
-  document.addEventListener('selectionchange', () => {
-    if (document.activeElement === editor) updateToolbarState();
-  });
-
-  // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
-  editor.addEventListener('keydown', (e) => {
-    const mod = e.ctrlKey || e.metaKey;
-    if (!mod) return;
-    const k = e.key.toLowerCase();
-    if (k === 'b') { e.preventDefault(); execCmd('bold'); }
-    else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
-    else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
-    else if (k === 'k') { e.preventDefault(); linkPrompt(); }
-  });
-
-  // Paste: keep it simple — strip formatting unless user wants it. Default
-  // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
-  // styles from clipboard only when shift is held — otherwise plain text.
-  editor.addEventListener('paste', (e) => {
-    if (e.shiftKey) return; // user wants formatted paste
-    const text = (e.clipboardData || window.clipboardData).getData('text/plain');
-    if (text == null) return;
-    e.preventDefault();
-    document.execCommand('insertText', false, text);
-  });
-
-  // ── Image upload (button + drag-drop into the editor)
-  async function uploadAndInsertImage(file) {
-    const edited = await openImageEditor(file);
-    if (!edited) return; // cancelled
-    contentStatus.classList.remove('is-error');
-    contentStatus.textContent = '<%= t('pedit.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 = '<%= t('pedit.js_inserted') %> ✓';
-      setTimeout(() => { contentStatus.textContent = ''; }, 2000);
-      updateCharCount();
-    } catch (e) {
-      contentStatus.classList.add('is-error');
-      contentStatus.textContent = '<%= t('pedit.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();
-  }
-
-  // ── 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('<%= t('pedit.js_embed_prompt') %>');
-      if (!raw) return;
-      const url = raw.trim();
-      if (!/^https?:\/\//i.test(url)) { alert('<%= t('pedit.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 ? '<%= t('pedit.js_no_tracks_found') %> ' + q : '<%= t('pedit.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 = '<%= t('pedit.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 = '<%= t('pedit.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();
-        }
-      }
-    });
-    document.addEventListener('keydown', (e) => {
-      if (!trackPicker.hidden && e.key === 'Escape') {
-        e.preventDefault();
-        closePicker();
-      }
-    });
-
-    // Live filter
-    tpSearch.addEventListener('input', () => renderList(tpSearch.value));
-  }
-
-  // ── Playlist insert (open existing or create new via modal)
-  const playlistBtn = document.getElementById('insert-playlist-btn');
-  if (playlistBtn) {
-    playlistBtn.addEventListener('click', async () => {
-      if (typeof window.openPlaylistEditor !== 'function') {
-        alert('<%= t('pedit.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(
-            '<%= t('pedit.js_playlist_existing') %>\n\n' +
-            j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
-            '\n\n<%= t('pedit.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');
-
-    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');
-      });
-      panels.forEach(p => { p.hidden = (p.dataset.panel !== tt); });
-    }
-    seg.addEventListener('click', (e) => {
-      const btn = e.target.closest('.pe-typeseg-btn');
-      if (btn) applyType(btn.dataset.type);
-    });
-    applyType(typeInput.value || 'post');
-
-    // 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('<%= t('pedit.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 = '⏳ <%= t('pedit.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 = '✓ <%= t('pedit.audio_up_done') %>';
-          li.classList.add('is-done');
-        } catch (err) {
-          stateEl.textContent = '✕ <%= t('pedit.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;
-    });
-  }
-})();
-</script>
+
 
 <%# ── Track picker modal (P59). Mobile-first: full-screen sheet on small
@@ -2229,61 +1214,3 @@
   <%- include('../partials/playlist-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
 <% } %>
-
-<script>
-(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 '<%= t('pedit.pin_top') %>';
-    return n + '<%= t('pedit.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();
-  window.addEventListener('resize', position);
-  if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
-  // has-audio-player is toggled via a body class → observe it.
-  try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
-})();
-</script>
+<%- include('../partials/page-data', { pageData: { _timezone: (typeof timezone !== 'undefined' ? timezone : ''), apply: t('imed.apply'), audio_up_busy: t('pedit.audio_up_busy'), audio_up_done: t('pedit.audio_up_done'), audio_up_fail: t('pedit.audio_up_fail'), cancel: t('imed.cancel'), chip_album: t('pedit.chip_album'), chip_playlist: t('pedit.chip_playlist'), chip_track: t('pedit.chip_track'), flip_h: t('imed.flip_h'), flip_v: t('imed.flip_v'), js_embed_invalid: t('pedit.js_embed_invalid'), js_embed_prompt: t('pedit.js_embed_prompt'), js_failed: t('pedit.js_failed'), js_inserted: t('pedit.js_inserted'), js_link_prompt: t('pedit.js_link_prompt'), js_no_tracks_found: t('pedit.js_no_tracks_found'), js_no_tracks_yet: t('pedit.js_no_tracks_yet'), js_playlist_choose: t('pedit.js_playlist_choose'), js_playlist_editor_missing: t('pedit.js_playlist_editor_missing'), js_playlist_existing: t('pedit.js_playlist_existing'), js_tracks_load_fail: t('pedit.js_tracks_load_fail'), js_tracks_loading: t('pedit.js_tracks_loading'), js_uploaded: t('pedit.js_uploaded'), js_uploading: t('pedit.js_uploading'), pin_nth_suffix: t('pedit.pin_nth_suffix'), pin_top: t('pedit.pin_top'), reset: t('imed.reset'), rotate_left: t('imed.rotate_left'), rotate_right: t('imed.rotate_right'), tb_done: t('pedit.tb_done'), tb_fullscreen: t('pedit.tb_fullscreen'), title: t('imed.title'), zoom_in: t('imed.zoom_in'), zoom_out: t('imed.zoom_out') } }) %>
Index: src/views/pages/post.ejs
===================================================================
--- src/views/pages/post.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/pages/post.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -147,51 +147,7 @@
     </section>
   <% } %>
-  <script>
-  (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);
-  })();
-  </script>
+  <%# Het remote-reply-veld zit in assets/js/mod/post.js. Inline script hier wordt
+    door de CSP geweigerd zodra je deze pagina via een link binnen de site opent
+    -- zie shaer-0i6. %>
 
 
Index: src/views/partials/bottom-tab.ejs
===================================================================
--- src/views/partials/bottom-tab.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/bottom-tab.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -302,17 +302,5 @@
 </style>
 
-<script>
-// Bottom-tab search button → opens #search-overlay. Using event delegation + looking
-// up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced
-// during htmx navigation, so a once-captured reference goes stale
-// (button did nothing afterwards). Guard against double-wiring.
-(function() {
-  if (window.__btSearchWired) return;
-  window.__btSearchWired = true;
-  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(); }
-  });
-})();
-</script>
+<%# De zoekknop zit nu in de chrome-module (assets/js/mod/chrome.js).
+    Inline script hier wordt door de CSP geweigerd zodra deze partial via htmx
+    binnenkomt, en dat is bij elke navigatie -- zie shaer-0i6. %>
Index: src/views/partials/msg-item.ejs
===================================================================
--- src/views/partials/msg-item.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/msg-item.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -1,2 +1,82 @@
+<% if (n.type === 'thread') { %>
+<%
+           var _tseen = (typeof seen !== 'undefined' && seen) ? seen : 0;
+           // Een draad is nieuw zodra EEN bericht erin nieuw is.
+           var _tnew = !!_tseen && (n.messages || []).some(function (m) {
+             return m.created_at && Date.parse(m.created_at) > _tseen;
+           });
+           var _tpeople = (n.people || []).map(function (p) { return p.name || p.handle || '?'; });
+           // Alleen jij aan het woord: dan is de tegenpartij het adres waaraan
+           // je schreef, niet een lege naam.
+           var _tto = ((n.messages || []).find(function (m) { return m.to_handle; }) || {}).to_handle;
+           var _tlabel = _tpeople.length ? _tpeople.join(', ') : (_tto || t('msg.you'));
+      %>
+        <li class="msg-item msg-thread<%= _tnew ? ' is-new' : '' %>" data-kind="conv" data-who="<%= (_tpeople.join(' ') + ' ' + (_tto || '')).toLowerCase() %>">
+          <div class="msg-thread-head">
+            <%# Tegenpartij en aantal in EEN uitsparing: het is een label bij het
+                gesprek, geen kop erboven. Zo houdt de inhoud de ruimte.
+
+                Deze uitsparing is ook de knop die het gesprek in- en uitklapt
+                (Barts aanwijzing): tikken op de tegenpartij is waar je van
+                nature naar wijst als je "dit gesprek" bedoelt. Een BUTTON en
+                geen span met een klikhandler, want dan werkt hij ook met het
+                toetsenbord en leest een schermlezer de stand voor.
+
+                Zonder JavaScript doet hij niets, en dan staat het gesprek
+                gewoon open -- dat is ook de stand waar we mee beginnen. %>
+            <button type="button" class="msg-thread-who" aria-expanded="true">
+              <span class="msg-thread-name"><%= _tlabel %></span>
+              <% if (n.count > 1) { %><span class="msg-thread-count"><%= n.count %></span><% } %>
+              <svg class="msg-thread-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
+            </button>
+            <% if (_tnew) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
+            <%# De context bij de draad: waar gaat dit gesprek over? Zonder deze
+                link is een antwoord in een lijst niet te plaatsen. %>
+            <% if (n.post) { %>
+              <a class="msg-thread-post" href="/<%= n.post.slug %>">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
+                <%= n.post.title || n.post.slug %>
+              </a>
+            <% } %>
+          </div>
+          <ul class="msg-thread-msgs">
+            <% (n.messages || []).forEach(function (m) { %><%- include('msg-item', { n: m, seen: _tseen, inThread: true }) %><% }); %>
+          </ul>
+          <%# Antwoorden vanuit de draad zelf. Ingeklapt, want een open editor
+              onder elk gesprek maakt de lijst onleesbaar -- juist het probleem
+              dat deze weergave moest oplossen. De route kiest het pad: een
+              draad aan een post krijgt een reply, een draad aan een persoon een
+              direct bericht. %>
+          <% var _rt = n.replyTo || {}; var _kan = canMutate && ((_rt.interactionId && _rt.postSlug) || _rt.actorUri); %>
+          <% if (_kan) { %>
+            <div class="msg-thread-foot">
+              <details class="msg-reply">
+                <summary><%= t('fedi.reply') %></summary>
+                <div class="msg-reply-form">
+                  <%- include('../partials/reply-editor', {
+                    action: (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') + '/messages/reply',
+                    hiddenFields: [
+                      { name: 'interaction_id', value: _rt.interactionId || '' },
+                      { name: 'post_slug', value: _rt.postSlug || '' },
+                      { name: 'to', value: _rt.actorUri || '' },
+                    ],
+                    placeholder: t('fedi.reply_ph'),
+                    submitLabel: t('fedi.reply'),
+                    rows: 2,
+                  }) %>
+                </div>
+              </details>
+              <%# De zwaai blijft een eigen knop: het is geen antwoord maar een
+                  seintje (FEP-633c), en die twee horen niet op een hoop. %>
+              <% if (_rt.actorUri) { %>
+                <form method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" class="msg-wave">
+                  <input type="hidden" name="to" value="<%= _rt.actorUri %>">
+                  <button class="btn small" name="text" value="&#128075;" title="<%= t('msg.wave_back') %>">&#128075;</button>
+                </form>
+              <% } %>
+            </div>
+          <% } %>
+        </li>
+<% } else { %>
 <%
            var _seen = (typeof seen !== 'undefined' && seen) ? seen : 0;
@@ -5,16 +85,22 @@
            var _who = n.name || n.handle || '';
            var _priv0 = (n.visibility === 'followers' || n.visibility === 'direct');
-           // Buckets (klonkt-demo-3jf): Messages = @mentions + private (DM) replies;
-           // Activity = likes/boosts/follows; Moderation = reports; Sent = your
-           // replies; Conversations = every other (public) reply.
-           var _kind = _t === 'sent' ? 'sent'
-             : (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act'
+           // Emmers: Activiteit = likes/boosts/follows/polls, Moderatie =
+           // reports, en al het overige is gesprek. De oude emmers 'msgs' en
+           // 'sent' bestaan niet meer sinds Berichten, Gesprekken en Verzonden
+           // samenvielen -- en dat is geen cosmetische hernoeming: een
+           // gespreksregel die om wat voor reden dan ook geen draad werd (geen
+           // post, geen herkenbare tegenpartij) hield anders een data-kind
+           // waarvoor geen chip meer bestond, en was daarmee onder ELKE chip
+           // behalve Alles onzichtbaar.
+           var _kind = (_t === 'like' || _t === 'boost' || _t === 'follow' || _t === 'poll_done') ? 'act'
              : _t === 'report' ? 'mod'
-             : (_t === 'mention' || _priv0) ? 'msgs'
              : 'conv';
+           // Binnen een draad is dit geen zelfstandige regel: de chip filtert op
+           // de draad, niet op de losse zinnen erin.
+           var _sub = (typeof inThread !== 'undefined' && inThread);
            var _init = String(_who || '?').replace(/^@/, '').charAt(0).toUpperCase();
            var _priv = (n.visibility === 'followers' || n.visibility === 'direct');
       %>
-        <li class="msg-item msg-<%= _t %><%= _new ? ' is-new' : '' %>" data-kind="<%= _kind %>" data-who="<%= String(_who).toLowerCase() %>">
+        <li class="<%= _sub ? 'msg-sub' : 'msg-item' %> msg-<%= _t %><%= _new ? ' is-new' : '' %>"<% if (!_sub) { %> data-kind="<%= _kind %>"<% } %> data-who="<%= String(_who).toLowerCase() %>">
           <span class="msg-av<%= _t === 'sent' ? ' msg-av-sent' : '' %>" aria-hidden="true">
             <% if (_t === 'sent') { %><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
@@ -38,8 +124,10 @@
               <% if (_t === 'sent') { %>
                 <span class="msg-who"><%= t('msg.you') %></span>
-                <% if (n.to_handle) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>
+                <% if (n.to_handle && !_sub) { %><span class="msg-handle">→ <%= n.to_handle %></span><% } %>
               <% } else { %>
                 <a class="msg-who" href="<%= n.url %>" target="_blank" rel="nofollow noopener"><%- emojiName(_who, n.actor_emoji_json) %></a>
-                <% if (n.handle && n.name) { %><span class="msg-handle"><%= n.handle %></span><% } %>
+                <% /* De naam blijft: binnen een draad moet je zien WIE wat zei.
+                      De handle erachter niet -- die staat in de kop. */ %>
+                <% if (n.handle && n.name && !_sub) { %><span class="msg-handle"><%= n.handle %></span><% } %>
               <% } %>
               <% if (_new) { %><span class="msg-new" title="<%= t('msg.new') %>"></span><% } %>
@@ -50,4 +138,8 @@
             </div>
 
+            <% var _hasWhat = !_sub || _priv
+                 || (_t === 'mention' && (n.help_request || n.wave || n.note_url))
+                 || (_t === 'sent' && n.in_reply_to); %>
+            <% if (_hasWhat) { %>
             <div class="msg-what">
               <% if (n.count && n.count > 1) { %>
@@ -60,4 +152,5 @@
               <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %>
               <% } else if (_t === 'mention' && n.wave) { %>👋 <%= t('msg.waved_at_you') %>
+              <% } else if (_sub) { %><%# binnen een draad zegt de kop al dat dit een gesprek is %>
               <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %>
               <% } else if (_t === 'sent') { %><%= t('msg.sent_reply') %>
@@ -65,8 +158,9 @@
               <% } else { %><%= t('notif.replied') %><% } %>
               <% if (_priv) { %><span class="msg-priv" title="<%= t('msg.private_hint') %>">🔒 <%= t('msg.private') %></span><% } %>
-              <% if (n.post_slug) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
+              <% if (n.post_slug && !_sub) { %><a class="msg-post" href="/<%= n.post_slug %>"><%= n.post_title || n.post_slug %></a><% } %>
               <% if (_t === 'mention' && n.note_url) { %><a class="msg-post" href="<%= n.note_url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
               <% if (_t === 'sent' && n.in_reply_to) { %><a class="msg-post" href="<%= n.in_reply_to %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
             </div>
+            <% } %>
 
             <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div>
@@ -116,2 +210,3 @@
           </div>
         </li>
+<% } %>
Index: src/views/partials/page-data.ejs
===================================================================
--- src/views/partials/page-data.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ src/views/partials/page-data.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,17 @@
+<%#
+  Servergegevens naar een module (shaer-bqr).
+
+  Een module is een statisch bestand, dus er kan geen EJS-tag in. Alles wat een
+  script van de server nodig heeft -- vertalingen, ids, instellingen -- komt
+  hierlangs, en de module leest het met pageData() uit mod/lib.js.
+
+  Waarom dit GEEN CSP-probleem is terwijl het een script-element is: een script
+  met een ander type dan JavaScript wordt niet uitgevoerd en niet opgehaald, dus
+  script-src heeft er niets over te zeggen. Het is een gegevensblok dat toevallig
+  in een script-tag staat, en het werkt daarmee ook in inhoud die via htmx
+  binnenkomt.
+
+  De kleiner-dan wordt ontsnapt: zonder dat sluit een sluitende script-tag IN de
+  gegevens dit blok voortijdig af, en dan staat de rest als HTML op de pagina.
+-%>
+<% if (typeof pageData !== 'undefined' && pageData) { %><script type="application/json" data-page-data><%- JSON.stringify(pageData).replace(/</g, '\\u003c') %></script><% } %>
Index: src/views/partials/playlist-editor.ejs
===================================================================
--- src/views/partials/playlist-editor.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/playlist-editor.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -280,408 +280,3 @@
 </style>
 
-<script>
-(function() {
-  // Idempotency: if window.openPlaylistEditor already defined (multiple
-  // partial includes on a single page), skip re-binding.
-  if (typeof window.openPlaylistEditor === 'function') return;
-
-  const CSRF = '<%= _csrf %>';
-
-  function esc(s) {
-    return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
-      '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
-    }[c]));
-  }
-  function fmtDur(sec) {
-    if (!sec) return '';
-    const m = Math.floor(sec / 60), s = sec % 60;
-    return `${m}:${String(s).padStart(2, '0')}`;
-  }
-
-  async function api(method, url, body) {
-    const opts = {
-      method, credentials: 'same-origin',
-      headers: { 'X-CSRF-Token': CSRF },
-    };
-    if (body !== undefined) {
-      opts.headers['Content-Type'] = 'application/json';
-      opts.body = JSON.stringify(body);
-    }
-    const r = await fetch(url, opts);
-    return r.json();
-  }
-
-  /**
-   * Public entry point: open the editor.
-   *  opts: { mode: 'create'|'edit', id?, onSaved? }
-   *  onSaved is called with { id, playlist } after a successful save.
-   */
-  window.openPlaylistEditor = async function(opts) {
-    opts = opts || {};
-    const mode = opts.mode === 'edit' ? 'edit' : 'create';
-    const isEdit = mode === 'edit';
-
-    // Load all audio tracks for the picker
-    let tracks = [];
-    try {
-      const j = await api('GET', '/admin/playlists/api/tracks');
-      if (Array.isArray(j.tracks)) tracks = j.tracks;
-    } catch (e) {
-      alert('Tracks ophalen mislukt');
-      return;
-    }
-    if (!tracks.length) {
-      alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
-      return;
-    }
-
-    // Existing playlist data when editing
-    let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };
-    if (isEdit && opts.id) {
-      try {
-        const j = await api('GET', '/admin/playlists/api/' + encodeURIComponent(opts.id));
-        if (j.ok) initial = { ...initial, ...j.playlist };
-      } catch (e) {}
-    }
-
-    // ── DOM ────────────────────────────────────────────────────────
-    const backdrop = document.createElement('div');
-    backdrop.className = 'pl-modal-backdrop';
-    backdrop.innerHTML = `
-      <div class="pl-modal" role="dialog" aria-label="Playlist editor">
-        <div class="pl-modal-header">
-          <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>
-          <button type="button" class="pl-modal-close" aria-label="Sluiten">×</button>
-        </div>
-        <div class="pl-modal-body">
-          <div class="pl-editor-cols">
-            <div class="pl-editor-left">
-              <div class="pl-meta-grid">
-                <label class="pl-field pl-field-full">
-                  <span>Titel *</span>
-                  <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
-                </label>
-                <label class="pl-field">
-                  <span>Artiest</span>
-                  <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
-                </label>
-                <label class="pl-field">
-                  <span>Jaar</span>
-                  <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
-                </label>
-                <label class="pl-field">
-                  <span>Type</span>
-                  <select id="pli-kind">
-                    <option value="album"    ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>
-                    <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>
-                  </select>
-                </label>
-                <div class="pl-field pl-field-full">
-                  <span>Cover</span>
-                  <div class="pl-cover-row">
-                    <span class="pl-cover-thumb" id="pli-cover-thumb">
-                      ${initial.cover
-                        ? `<img src="${esc(initial.cover)}" alt="">`
-                        : `<span class="pl-cover-empty">🎨</span>`}
-                    </span>
-                    <input type="text" id="pli-cover" placeholder="https://… of upload" value="${esc(initial.cover)}" style="flex:1">
-                  </div>
-                  <div class="pl-cover-upload-row">
-                    <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden>
-                    <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>
-                    <span class="pl-cover-status" id="pli-cover-status"></span>
-                  </div>
-                </div>
-              </div>
-              <div class="pl-section-title">
-                Tracks in playlist <span class="pl-track-count" id="pli-count">0</span>
-                <small>(sleep ⠿ om te ordenen)</small>
-              </div>
-              <div id="pli-selected" class="pl-selected-list"></div>
-            </div>
-            <div class="pl-editor-right">
-              <div class="pl-section-title">Beschikbare tracks</div>
-              <input type="search" id="pli-search" placeholder="Zoek..." class="pl-search-input">
-              <div id="pli-available" class="pl-available-list"></div>
-            </div>
-          </div>
-        </div>
-        <div class="pl-modal-footer">
-          <button type="button" class="btn" id="pli-cancel">Annuleren</button>
-          <button type="button" class="btn btn-primary" id="pli-save" disabled>
-            ${isEdit ? 'Opslaan' : 'Aanmaken'}
-          </button>
-        </div>
-      </div>
-    `;
-    document.body.appendChild(backdrop);
-
-    const $ = sel => backdrop.querySelector(sel);
-    const close = () => backdrop.remove();
-    backdrop.addEventListener('click', e => { if (e.target === backdrop) close(); });
-    $('.pl-modal-close').addEventListener('click', close);
-    $('#pli-cancel').addEventListener('click', close);
-
-    // Index tracks by id for fast lookup
-    const trackById = new Map(tracks.map(t => [t.id, t]));
-    let selected = (initial.track_ids || []).filter(id => trackById.has(id));
-
-    const titleEl = $('#pli-title');
-    const artistEl = $('#pli-artist');
-    const yearEl = $('#pli-year');
-    const kindEl = $('#pli-kind');
-    const coverEl = $('#pli-cover');
-    const coverThumb = $('#pli-cover-thumb');
-    const saveBtn = $('#pli-save');
-    const selectedEl = $('#pli-selected');
-    const availEl = $('#pli-available');
-    const searchEl = $('#pli-search');
-    const countEl = $('#pli-count');
-
-    coverEl.addEventListener('input', () => {
-      const u = coverEl.value.trim();
-      coverThumb.innerHTML = u
-        ? `<img src="${esc(u)}" alt="" 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 'Aanmaken'/'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 = 'Alleen afbeeldingen';
-          coverStatus.className = 'pl-cover-status is-error';
-          return;
-        }
-        coverStatus.textContent = 'Uploaden…';
-        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 || 'Upload mislukt');
-          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 = 'Mislukt: ' + err.message;
-          coverStatus.className = 'pl-cover-status is-error';
-        }
-      });
-    }
-
-    function updateSaveBtn() {
-      saveBtn.disabled = !titleEl.value.trim() || selected.length === 0;
-    }
-
-    function renderSelected() {
-      countEl.textContent = selected.length;
-      if (selected.length === 0) {
-        selectedEl.innerHTML = '<div class="pl-empty">Klik tracks rechts om toe te voegen.</div>';
-        updateSaveBtn();
-        return;
-      }
-      selectedEl.innerHTML = selected.map((id, i) => {
-        const t = trackById.get(id);
-        if (!t) return '';
-        const cover = t.cover
-          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
-          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
-        return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}">
-          <span class="pl-row-handle" aria-label="Verslepen">⠿</span>
-          <span class="pl-row-num">${i + 1}</span>
-          ${cover}
-          <span class="pl-row-info">
-            <span class="pl-row-title">${esc(t.title)}</span>
-            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
-          </span>
-          <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="Verwijderen">×</button>
-        </div>`;
-      }).join('');
-
-      selectedEl.querySelectorAll('.pl-row-x').forEach(b => {
-        b.addEventListener('click', () => {
-          selected = selected.filter(x => x !== b.dataset.id);
-          renderSelected();
-          renderAvailable();
-        });
-      });
-      bindDrag();
-      updateSaveBtn();
-    }
-
-    function renderAvailable() {
-      const q = searchEl.value.trim().toLowerCase();
-      const matches = tracks.filter(t => {
-        if (!q) return true;
-        return (t.title || '').toLowerCase().includes(q)
-            || (t.artist || '').toLowerCase().includes(q);
-      });
-      if (matches.length === 0) {
-        availEl.innerHTML = '<div class="pl-empty">Geen resultaten.</div>';
-        return;
-      }
-      availEl.innerHTML = matches.map(t => {
-        const isAdded = selected.includes(t.id);
-        const cls = ['pl-avail-row'];
-        if (isAdded) cls.push('is-added');
-        if (!t.playable) cls.push('is-unplayable');
-        const cover = t.cover
-          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
-          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
-        return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>
-          ${cover}
-          <span class="pl-row-info">
-            <span class="pl-row-title">${esc(t.title)}</span>
-            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}${t.duration ? ' · ' + fmtDur(t.duration) : ''}</span>` : ''}
-          </span>
-          <span class="pl-avail-action">${isAdded ? '✓' : '+'}</span>
-        </div>`;
-      }).join('');
-
-      availEl.querySelectorAll('.pl-avail-row').forEach(row => {
-        if (row.classList.contains('is-unplayable')) return;
-        row.addEventListener('click', () => {
-          const id = row.dataset.id;
-          if (selected.includes(id)) selected = selected.filter(x => x !== id);
-          else selected.push(id);
-          renderSelected();
-          renderAvailable();
-        });
-      });
-    }
-
-    // Pointer-based drag-to-reorder. Same pattern as v9's admin.js.
-    function bindDrag() {
-      selectedEl.querySelectorAll('.pl-row').forEach(row => {
-        const handle = row.querySelector('.pl-row-handle');
-        if (!handle) return;
-        let dragging = false, originalIdx = -1;
-
-        handle.addEventListener('pointerdown', e => {
-          e.preventDefault();
-          handle.setPointerCapture(e.pointerId);
-          dragging = true;
-          originalIdx = parseInt(row.dataset.pos, 10);
-          row.classList.add('is-dragging');
-        });
-        handle.addEventListener('pointermove', e => {
-          if (!dragging) return;
-          e.preventDefault();
-          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
-          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
-          let targetIdx = -1, above = false;
-          for (let i = 0; i < rows.length; i++) {
-            const r = rows[i]; if (r === row) continue;
-            const rect = r.getBoundingClientRect();
-            const mid = rect.top + rect.height / 2;
-            if (e.clientY < mid && targetIdx === -1) { targetIdx = i; above = true; break; }
-            if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
-              targetIdx = i; above = e.clientY < mid; break;
-            }
-          }
-          if (targetIdx !== -1) rows[targetIdx].classList.add(above ? 'drop-above' : 'drop-below');
-        });
-        const finish = e => {
-          if (!dragging) return;
-          dragging = false;
-          try { handle.releasePointerCapture(e.pointerId); } catch (_) {}
-          row.classList.remove('is-dragging');
-          const rows = Array.from(selectedEl.querySelectorAll('.pl-row'));
-          let targetIdx = -1, above = false;
-          for (let i = 0; i < rows.length; i++) {
-            if (rows[i].classList.contains('drop-above')) { targetIdx = i; above = true; break; }
-            if (rows[i].classList.contains('drop-below')) { targetIdx = i; above = false; break; }
-          }
-          rows.forEach(r => r.classList.remove('drop-above', 'drop-below'));
-          if (targetIdx === -1 || targetIdx === originalIdx) return;
-          const moved = selected[originalIdx];
-          selected.splice(originalIdx, 1);
-          let newIdx = targetIdx;
-          if (originalIdx < targetIdx) newIdx--;
-          if (!above) newIdx++;
-          newIdx = Math.max(0, Math.min(selected.length, newIdx));
-          selected.splice(newIdx, 0, moved);
-          renderSelected();
-        };
-        handle.addEventListener('pointerup', finish);
-        handle.addEventListener('pointercancel', () => {
-          dragging = false;
-          row.classList.remove('is-dragging');
-          selectedEl.querySelectorAll('.drop-above, .drop-below')
-            .forEach(r => r.classList.remove('drop-above', 'drop-below'));
-        });
-      });
-    }
-
-    titleEl.addEventListener('input', updateSaveBtn);
-    searchEl.addEventListener('input', renderAvailable);
-    renderSelected();
-    renderAvailable();
-
-    saveBtn.addEventListener('click', async () => {
-      if (saveBtn.disabled) return;
-      const orig = saveBtn.textContent;
-      saveBtn.disabled = true;
-      saveBtn.textContent = 'Opslaan...';
-
-      const payload = {
-        title:  titleEl.value.trim(),
-        artist: artistEl.value.trim(),
-        year:   parseInt(yearEl.value, 10) || 0,
-        cover:  coverEl.value.trim(),
-        kind:   kindEl.value === 'playlist' ? 'playlist' : 'album',
-        tracks: selected.slice(),
-      };
-
-      try {
-        const url = isEdit
-          ? '/admin/playlists/api/' + encodeURIComponent(initial.id)
-          : '/admin/playlists/api';
-        const j = await api('POST', url, payload);
-        if (!j.ok) {
-          alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
-          saveBtn.disabled = false;
-          saveBtn.textContent = orig;
-          return;
-        }
-        const savedId = isEdit ? initial.id : j.id;
-        close();
-        if (typeof opts.onSaved === 'function') {
-          opts.onSaved({ id: savedId, playlist: payload });
-        }
-      } catch (err) {
-        alert('Opslaan mislukt: ' + err.message);
-        saveBtn.disabled = false;
-        saveBtn.textContent = orig;
-      }
-    });
-
-    // ESC to close
-    document.addEventListener('keydown', function onEsc(e) {
-      if (e.key === 'Escape' && document.body.contains(backdrop)) {
-        close();
-        document.removeEventListener('keydown', onEsc);
-      }
-    });
-  };
-})();
-</script>
+<%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
Index: src/views/partials/profile-header.ejs
===================================================================
--- src/views/partials/profile-header.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/profile-header.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -160,61 +160,7 @@
 </div>
 <% } %>
-<script>
-(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(); });
-})();
-</script>
+<%# Het script van de profielkop staat in assets/js/mod/chrome.js: deze partial
+    zit in de OOB-chrome en wordt bij elke htmx-navigatie opnieuw ingevoegd
+    (shaer-bqr). %>
 <% } %>
 
Index: src/views/partials/profile-sheet.ejs
===================================================================
--- src/views/partials/profile-sheet.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/profile-sheet.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -105,5 +105,5 @@
           <svg class="psi-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
           <span class="psi-label"><%= t('nav.theme_label') %></span>
-          <span class="psi-meta" id="profile-sheet-theme-state"><%= t('nav.dark') %></span>
+          <span class="psi-meta" id="profile-sheet-theme-state" data-dark="<%= t('nav.dark') %>" data-light="<%= t('nav.light') %>"><%= t('nav.dark') %></span>
         </button>
       </li>
@@ -326,98 +326,3 @@
 </style>
 
-<script>
-(function() {
-  const sheet = document.getElementById('profile-sheet');
-  if (!sheet) return;
-
-  const backdrop  = document.getElementById('profile-sheet-backdrop');
-  const panel     = sheet.querySelector('.profile-sheet-panel');
-  const closeBtn  = document.getElementById('profile-sheet-close');
-  const dragZone  = document.getElementById('profile-sheet-drag-zone');
-  const themeBtn  = document.getElementById('profile-sheet-theme');
-  const themeLbl  = document.getElementById('profile-sheet-theme-state');
-
-  function openSheet() {
-    sheet.classList.add('is-open');
-    sheet.setAttribute('aria-hidden', 'false');
-    document.body.classList.add('profile-sheet-locked');
-    syncTheme();
-  }
-  function closeSheet() {
-    sheet.classList.remove('is-open');
-    sheet.setAttribute('aria-hidden', 'true');
-    document.body.classList.remove('profile-sheet-locked');
-    panel.style.removeProperty('--pcms-drag-y');
-  }
-
-  // Open: any element with [data-profile-sheet-toggle]
-  document.addEventListener('click', function(e) {
-    const trigger = e.target.closest('[data-profile-sheet-toggle]');
-    if (trigger) {
-      e.preventDefault();
-      openSheet();
-    }
-  });
-
-  // Close: backdrop tap, handle tap, ESC, or any [data-close-sheet] item
-  if (backdrop) backdrop.addEventListener('click', closeSheet);
-  if (closeBtn) closeBtn.addEventListener('click', closeSheet);
-  document.addEventListener('keydown', (e) => {
-    if (e.key === 'Escape' && sheet.classList.contains('is-open')) closeSheet();
-  });
-
-  // Menu items that navigate: close synchronously before nav fires
-  sheet.querySelectorAll('[data-close-sheet]').forEach((el) => {
-    el.addEventListener('click', closeSheet);
-  });
-
-  // Drag-down-to-close on touch.
-  // Uses --pcms-drag-y custom property rather than overwriting the panel's
-  // transform string. This composes correctly with any horizontal centering
-  // (currently none here, but the pattern matches audio-sheet for safety).
-  let startY = 0, lastY = 0, dragging = false;
-  function onDown(e) {
-    if (e.pointerType !== 'touch') return;
-    if (panel.scrollTop > 0) return;
-    startY = lastY = e.clientY;
-    dragging = true;
-    panel.classList.add('is-dragging');
-  }
-  function onMove(e) {
-    if (!dragging) return;
-    lastY = e.clientY;
-    const dy = Math.max(0, lastY - startY);
-    panel.style.setProperty('--pcms-drag-y', dy + 'px');
-  }
-  function onUp() {
-    if (!dragging) return;
-    dragging = false;
-    panel.classList.remove('is-dragging');
-    const dy = lastY - startY;
-    if (dy > 80) closeSheet();
-    else panel.style.removeProperty('--pcms-drag-y');
-  }
-  if (window.PointerEvent && dragZone) {
-    dragZone.addEventListener('pointerdown', onDown);
-    document.addEventListener('pointermove', onMove);
-    document.addEventListener('pointerup', onUp);
-    document.addEventListener('pointercancel', onUp);
-  }
-
-  // Theme toggle inside sheet — syncs label
-  function syncTheme() {
-    if (!themeLbl) return;
-    const t = document.documentElement.getAttribute('data-theme') || 'dark';
-    themeLbl.textContent = t === 'dark' ? '<%= t('nav.dark') %>' : '<%= t('nav.light') %>';
-  }
-  if (themeBtn) {
-    themeBtn.addEventListener('click', function() {
-      const cur = document.documentElement.getAttribute('data-theme') || 'dark';
-      const next = cur === 'dark' ? 'light' : 'dark';
-      document.documentElement.setAttribute('data-theme', next);
-      try { localStorage.setItem('pcms-theme', next); } catch (e) {}
-      syncTheme();
-    });
-  }
-})();
-</script>
+<%# Het script van het profielblad staat in assets/js/mod/chrome.js (shaer-bqr). %>
Index: src/views/partials/topnav.ejs
===================================================================
--- src/views/partials/topnav.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/topnav.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -176,5 +176,8 @@
 
 <!-- Search overlay (shared by mobile bottom-tab + desktop top-nav) -->
-<div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search">
+<%# De teksten staan op het element dat ze gebruikt, niet in het script: een
+    module is een statisch bestand en kan geen vertaling interpoleren. %>
+<div id="search-overlay" class="search-overlay" hidden data-suggest="<%= _siteUrlBase %>/search/suggest" data-action="<%= _siteUrlBase %>/search"
+     data-i18n="<%= JSON.stringify({ posts: t('search.section_posts'), tracks: t('search.section_tracks'), events: t('search.section_events'), pages: t('search.section_pages'), all: t('search.suggest_all'), none: t('search.suggest_empty') }) %>">
   <div class="container">
     <form class="search-overlay-form" method="get" action="<%= _siteUrlBase %>/search">
@@ -191,85 +194,5 @@
 </div>
 
-<script>
-(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 ───────────────────────────────────
-  var _ST = {
-    posts: '<%= t('search.section_posts') %>', tracks: '<%= t('search.section_tracks') %>',
-    events: '<%= t('search.section_events') %>', pages: '<%= t('search.section_pages') %>',
-    all: '<%= t('search.suggest_all') %>', none: '<%= t('search.suggest_empty') %>'
-  };
-  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; }
-  });
-})();
-</script>
+<%# Het script van de topnav staat in assets/js/mod/chrome.js (shaer-bqr). %>
 
 <style>
Index: src/views/partials/track-editor.ejs
===================================================================
--- src/views/partials/track-editor.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/partials/track-editor.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -284,477 +284,3 @@
 </style>
 
-<script>
-(function() {
-
-  function esc(s) {
-    return String(s == null ? '' : s)
-      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
-      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
-  }
-
-  async function api(method, url, body) {
-    const opts = { method, credentials: 'same-origin', headers: {} };
-    if (body !== undefined) {
-      if (body instanceof FormData) {
-        opts.body = body;
-      } else {
-        opts.headers['Content-Type'] = 'application/json';
-        opts.body = JSON.stringify(body);
-      }
-    }
-    const res = await fetch(url, opts);
-    let data;
-    try { data = await res.json(); }
-    catch (_) { throw new Error('Onverwacht antwoord (' + res.status + ')'); }
-    if (!res.ok) data.ok = false;
-    return data;
-  }
-
-  /**
-   * Open the track editor.
-   * @param {object} opts
-   * @param {string} opts.id        — track id to edit
-   * @param {function?} opts.onSaved — called with updated track on success
-   */
-  window.openTrackEditor = async function openTrackEditor({ id, onSaved }) {
-    if (!id) return;
-    let track, albumSuggestions = [];
-    try {
-      const [trackJson, listJson] = await Promise.all([
-        api('GET', '/admin/audio/api/' + encodeURIComponent(id)),
-        api('GET', '/admin/audio/api/albums'),
-      ]);
-      if (!trackJson.ok) throw new Error(trackJson.error || 'Track niet gevonden');
-      track = trackJson.track;
-      if (listJson.ok && Array.isArray(listJson.albums)) {
-        albumSuggestions = listJson.albums.filter(Boolean);
-      }
-    } catch (err) {
-      alert('Track ophalen mislukt: ' + err.message);
-      return;
-    }
-
-    const backdrop = document.createElement('div');
-    backdrop.className = 'te-backdrop';
-    backdrop.innerHTML = `
-      <div class="te-modal" role="dialog" aria-modal="true" aria-label="Track bewerken">
-        <div class="te-handle" aria-hidden="true"><span class="te-handle-bar"></span></div>
-
-        <div class="te-header">
-          <h3>✎ Track bewerken</h3>
-          <button type="button" class="te-close" aria-label="Sluiten">×</button>
-        </div>
-
-        <div class="te-body">
-
-          ${track.stream_url ? `
-          <div class="te-preview">
-            <div class="te-preview-cover">
-              ${track.cover_url
-                ? `<img src="${esc(track.cover_url)}" alt="">`
-                : `🎵`}
-            </div>
-            <div class="te-preview-meta">
-              <div class="te-preview-title">${esc(track.title || '(zonder titel)')}</div>
-              <div class="te-preview-sub">
-                ${esc(track.artist || '—')}${track.album ? ' · ' + esc(track.album) : ''}
-              </div>
-            </div>
-            <button type="button" class="te-preview-play" id="te-preview-play" aria-label="Afspelen">▶</button>
-          </div>
-          ` : ''}
-
-          <div class="te-form">
-
-            <label class="te-field">
-              <span>Titel <span class="te-required" aria-hidden="true">*</span></span>
-              <input type="text" id="te-title" maxlength="200" required
-                     autocomplete="off" autocapitalize="words" spellcheck="false"
-                     value="${esc(track.title || '')}">
-            </label>
-
-            <div class="te-row te-row-2">
-              <label class="te-field">
-                <span>Artiest</span>
-                <input type="text" id="te-artist" maxlength="200"
-                       autocomplete="off" autocapitalize="words" spellcheck="false"
-                       value="${esc(track.artist || '')}">
-              </label>
-              <label class="te-field">
-                <span>Album</span>
-                <input type="text" id="te-album" maxlength="200"
-                       autocomplete="off" autocapitalize="words" spellcheck="false"
-                       list="te-album-list" value="${esc(track.album || '')}">
-                <datalist id="te-album-list">
-                  ${albumSuggestions.map(a => `<option value="${esc(a)}">`).join('')}
-                </datalist>
-              </label>
-            </div>
-
-            <label class="te-field">
-              <span>Duur <small>(seconden — 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);
-  };
-})();
-</script>
+<%# Het script van deze pagina staat in assets/js/mod/track-editor.js (shaer-bqr). %>
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ src/views/shell.ejs	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -339,5 +339,5 @@
 </head>
 
-<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
+<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-js="chrome<%= (typeof pageJs !== 'undefined' && pageJs) ? ' ' + pageJs : '' %>" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
 
 <% if (typeof isViewer !== 'undefined' && isViewer) { %>
@@ -449,4 +449,10 @@
     }
 
+    // Welke modules deze pagina wil (shaer-bqr). De bootstrap hieronder leest
+    // dit zodra deze handler klaar is.
+    if (typeof d.js === 'string' && /^[a-z0-9 -]*$/.test(d.js)) {
+      document.body.setAttribute('data-js', d.js);
+    }
+
     var next = d.bodyClass;
     if (!next) return;
@@ -467,4 +473,41 @@
   //  de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere
   //  htmx:historyRestore-handler is vervallen nu htmx-history uitstaat.)
+})();
+</script>
+
+<!-- Modules laden (shaer-bqr). Inline script in gewisselde inhoud wordt door de
+     CSP geweigerd: de nonce rouleert per verzoek, dus een script dat via htmx
+     binnenkomt draagt er een die dit document niet kent (shaer-0i6). Alles wat
+     bij een pagina hoort komt daarom uit een module, en die wordt HIER geladen —
+     vanuit de shell, die alleen bij een volledige laadbeurt binnenkomt en dus
+     wél de goede nonce heeft.
+
+     Een dynamische import vanuit een vertrouwd (genonced) script is precies waar
+     'strict-dynamic' voor bedoeld is, dus de module zelf heeft geen nonce nodig. -->
+<script>
+(function () {
+  if (window.__modBoot) return;
+  window.__modBoot = true;
+
+  var loaded = {};
+  function load() {
+    var names = (document.body.getAttribute('data-js') || '').trim().split(/\s+/);
+    names.forEach(function (name) {
+      // Streng: deze waarde komt uit een template en wordt een PAD. Alleen
+      // kleine letters, cijfers en streepjes; nooit een punt of een schuine
+      // streep.
+      if (!name || loaded[name] || !/^[a-z0-9-]+$/.test(name)) return;
+      loaded[name] = 1;
+      import('/assets/js/mod/' + name + '.js').catch(function (e) {
+        console.warn('[mod] ' + name + ' laadde niet:', e && e.message);
+      });
+    });
+  }
+  load();
+  // Bij een htmx-navigatie wisselt de INHOUD, niet de body. De nav-trigger
+  // hieronder zet data-js opnieuw; daarna halen we op wat er nieuw bij staat.
+  // Een module die er al is wordt niet opnieuw geimporteerd -- vandaar dat elke
+  // module gedelegeerd moet werken en tegen een tweede aanroep moet kunnen.
+  document.body.addEventListener('pcmsNav', load);
 })();
 </script>
Index: test/archive-export.test.js
===================================================================
--- test/archive-export.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/archive-export.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,227 @@
+// De exporter (shaer-1a6), getoetst aan het formaat in docs/EXPORT-FORMAT.md.
+//
+// De acceptatietest uit dat document is de MOEILIJKSTE post die Klonkt kan
+// maken, niet de makkelijkste: een poll met bijlagen, een quote-kaart, een
+// content warning en betaalde toegang. Die staat hieronder als eerste.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import crypto from 'crypto';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+// De media-map moet bestaan voordat paths.js hem uitleest.
+const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-media-'));
+process.env.MEDIA_PATH = MEDIA;
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AX = await import('../src/services/ArchiveExportService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
+
+fs.mkdirSync(path.join(MEDIA, '2026'), { recursive: true });
+const PLAATJE = Buffer.from('dit-zijn-de-bytes-van-een-plaatje');
+fs.writeFileSync(path.join(MEDIA, '2026', 'cover.jpg'), PLAATJE);
+const PLAATJE_HASH = crypto.createHash('sha256').update(PLAATJE).digest('hex');
+
+function post(id, extra = {}) {
+  const kol = {
+    id, site_id: 's1', slug: id, author_id: 'u1', title: 'Titel', content: '<p>inhoud</p>',
+    status: 'published', published_at: '2026-08-01 10:00:00', created_at: '2026-08-01 10:00:00',
+    updated_at: '2026-08-01 10:00:00', ...extra,
+  };
+  const namen = Object.keys(kol);
+  db.prepare(`INSERT INTO posts (${namen.join(',')}) VALUES (${namen.map(() => '?').join(',')})`)
+    .run(...namen.map((n) => kol[n]));
+}
+
+const lees = (uit, pad) => JSON.parse(uit.files.get(pad).toString('utf8'));
+
+test('de moeilijkste post overleeft de export heel', () => {
+  post('zwaar', {
+    title: 'Een zware post',
+    content: '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">',
+    content_warning: 'spoiler', nsfw: 1, paid: 1, paid_min_cents: 500,
+    quote_uri: 'https://mstdn.social/users/x/statuses/1', quote_actor: 'https://mstdn.social/users/x',
+    poll_json: JSON.stringify({ multiple: false, options: [{ name: 'ja' }, { name: 'nee' }], endTime: '2026-08-09T10:00:00Z' }),
+    language: 'nl', tags: 'muziek, code',
+  });
+  const uit = AX.buildArchive('me');
+  const o = lees(uit, 'posts/zwaar.json');
+
+  assert.equal(o.type, 'Question', 'een poll exporteert als Question');
+  assert.deepEqual(o.oneOf.map((x) => x.name), ['ja', 'nee']);
+  assert.equal(o.endTime, '2026-08-09T10:00:00Z');
+  assert.equal(o.summary, 'spoiler', 'de content warning is de AS2 summary');
+  assert.equal(o.sensitive, true);
+  assert.equal(o.quoteUrl, 'https://mstdn.social/users/x/statuses/1');
+  assert.equal(o['shaer:quoteActor'], 'https://mstdn.social/users/x');
+  assert.equal(o['shaer:paidMinCents'], 500);
+  assert.deepEqual(o.contentMap, { nl: '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">' });
+  assert.deepEqual(o.tag.map((t) => t.name), ['#muziek', '#code']);
+});
+
+test('een betaalde post exporteert de VOLLEDIGE inhoud, niet de teaser', () => {
+  // buildNote() federeert bewust alleen een teaser. Zou de export die projectie
+  // volgen, dan archiveer je je eigen betaalde post als samenvatting.
+  const o = lees(AX.buildArchive('me'), 'posts/zwaar.json');
+  assert.match(o.content, /<p>body<\/p>/);
+  assert.equal(o['shaer:paid'], true);
+});
+
+test('de titel staat in name, niet in de content gebakken', () => {
+  // Ook een verschil met de federatie-projectie: Mastodon negeert `name`, dus
+  // daar gaat de titel de content in. Een archief hoort dat niet over te nemen.
+  const o = lees(AX.buildArchive('me'), 'posts/zwaar.json');
+  assert.equal(o.name, 'Een zware post');
+  assert.equal(o.type, 'Question');
+  assert.doesNotMatch(o.content, /<strong>Een zware post<\/strong>/);
+});
+
+test('media reizen mee als bytes, gesleuteld op hun inhoud', () => {
+  const uit = AX.buildArchive('me');
+  const o = lees(uit, 'posts/zwaar.json');
+  const a = o.attachment[0];
+  assert.equal(a['shaer:availability'], 'included');
+  assert.equal(a['shaer:sha256'], PLAATJE_HASH);
+  assert.equal(a.url, `media/${PLAATJE_HASH}.jpg`, 'de verwijzing is container-relatief');
+  assert.equal(a['shaer:originalUrl'], 'https://klonkt.test/media/2026/cover.jpg');
+  assert.deepEqual(uit.files.get(a.url), PLAATJE, 'en de bytes zitten er echt in');
+});
+
+test('een verdwenen bestand wordt gemeld, niet weggelaten', () => {
+  // De derde mediastaat. Een lege medialijst zou een leugen zijn en een
+  // verwijzing naar een ontbrekend bestand een kapot archief.
+  post('kwijt', { content: '<p>x</p><img src="/media/2026/weg.jpg">' });
+  const uit = AX.buildArchive('me');
+  const a = lees(uit, 'posts/kwijt.json').attachment[0];
+  assert.equal(a['shaer:availability'], 'missing');
+  assert.equal(a.url, 'https://klonkt.test/media/2026/weg.jpg', 'de oorspronkelijke plek blijft staan');
+  assert.equal(uit.counts.mediaMissing, 1);
+  assert.equal(uit.missing[0].post, 'kwijt');
+});
+
+test('een pad buiten de media-map wordt niet gevolgd', () => {
+  // Zonder deze grens trekt een verzonnen pad in oude inhoud een willekeurig
+  // bestand van de schijf het archief in.
+  post('sluipweg', { content: '<p>x</p><img src="/media/../../../etc/passwd">' });
+  const uit = AX.buildArchive('me');
+  const a = lees(uit, 'posts/sluipweg.json').attachment[0];
+  assert.equal(a['shaer:availability'], 'missing');
+});
+
+test('antwoorden van anderen staan apart en zijn gemarkeerd als archief', () => {
+  db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, content, created_at)
+              VALUES ('reply', 'zwaar', ?, ?, 'Anna', '<p>hoi</p>', '2026-08-02 10:00:00')`)
+    .run('https://mstdn.social/users/anna/statuses/9', 'https://mstdn.social/users/anna');
+  const uit = AX.buildArchive('me');
+  const c = lees(uit, 'replies/zwaar.json');
+  assert.equal(c['shaer:archive'], true, 'hierop keert een importer: nooit opnieuw bezorgen');
+  assert.equal(c.totalItems, 1);
+  assert.equal(c.orderedItems[0].id, 'https://mstdn.social/users/anna/statuses/9');
+  assert.equal(uit.counts.replies, 1);
+});
+
+test('een gehoste track reist mee, met de gegevens die alleen in de database staan', () => {
+  // [[track:]] verwijst naar een audio_tracks-rij met een media-rij eronder. Op
+  // beta staan nul tracks, dus dit pad raakt daar geen echte data -- vandaar hier.
+  fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
+  const WAV = Buffer.from('RIFF-nep-audio');
+  fs.writeFileSync(path.join(MEDIA, 'audio', 't1.mp3'), WAV);
+  db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+    .run('m1', 's1', 't1.mp3', 'audio/mpeg', WAV.length, path.join(MEDIA, 'audio', 't1.mp3'));
+  db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, credit, license, link_spotify)
+              VALUES ('tr1','s1','Kanonnen','Robin',212,'m1','opname 2026','CC BY','https://open.spotify.com/track/x')`).run();
+  post('metaudio', { content: '<p>luister</p>[[track:tr1]]' });
+
+  const uit = AX.buildArchive('me');
+  const o = lees(uit, 'posts/metaudio.json');
+  const a = o.attachment[0];
+  assert.equal(a['shaer:availability'], 'included');
+  assert.equal(a.mediaType, 'audio/mpeg');
+  assert.deepEqual(uit.files.get(a.url), WAV);
+  assert.match(o.content, /\[\[track:tr1\]\]/, 'de shorthand blijft in de bron staan');
+  const t = o['shaer:audio'][0];
+  assert.equal(t.name, 'Kanonnen');
+  assert.equal(t.artist, 'Robin');
+  assert.equal(t.license, 'CC BY');
+  assert.deepEqual(t.url, ['https://open.spotify.com/track/x']);
+});
+
+test('de poster van een audio-bijlage gaat mee', () => {
+  // Gevonden bij de export van echte beta-data: c2s_attachments draagt naast url
+  // ook een poster, en die viel er stil uit.
+  fs.writeFileSync(path.join(MEDIA, '2026', 'opname.m4a'), Buffer.from('audio'));
+  fs.writeFileSync(path.join(MEDIA, '2026', 'opname.m4a.poster.webp'), Buffer.from('poster'));
+  post('metposter', {
+    c2s_attachments: JSON.stringify([{
+      url: '/media/2026/opname.m4a', mediaType: 'audio/mp4', name: 'opname.m4a',
+      poster: '/media/2026/opname.m4a.poster.webp',
+    }]),
+  });
+  const o = lees(AX.buildArchive('me'), 'posts/metposter.json');
+  assert.equal(o.attachment.length, 2, 'de opname en de poster');
+  assert.ok(o.attachment.every((a) => a['shaer:availability'] === 'included'));
+});
+
+test('een concept gaat gewoon mee', () => {
+  post('concept', { status: 'draft', published_at: null });
+  const o = lees(AX.buildArchive('me'), 'posts/concept.json');
+  assert.equal(o['shaer:status'], 'draft');
+});
+
+test('twee exports van ongewijzigde inhoud zijn gelijk, op exportedAt na', () => {
+  const a = AX.buildArchive('me', { exportedAt: 'X' });
+  const b = AX.buildArchive('me', { exportedAt: 'Y' });
+  assert.deepEqual([...a.files.keys()].sort(), [...b.files.keys()].sort());
+  for (const pad of a.files.keys()) {
+    if (pad === 'manifest.json') continue;
+    assert.deepEqual(a.files.get(pad), b.files.get(pad), `${pad} verschilt`);
+  }
+  assert.deepEqual(a.manifest.files, b.manifest.files, 'de checksum-kaart hoort gelijk te zijn');
+});
+
+test('en de zip die eruit komt ook', () => {
+  const a = AX.buildArchive('me', { exportedAt: 'X' });
+  const b = AX.buildArchive('me', { exportedAt: 'X' });
+  assert.deepEqual(AX.zipArchive(a.files), AX.zipArchive(b.files), 'zonder vaste mtime en volgorde is dit toeval');
+});
+
+test('de zip is door unzip te lezen', async () => {
+  // Een zelfgeschreven zip die alleen zichzelf kan lezen is geen uitwisselformaat.
+  const uit = AX.buildArchive('me');
+  const zip = path.join(MEDIA, 'test.zip');
+  fs.writeFileSync(zip, AX.zipArchive(uit.files));
+  const { execFileSync } = await import('child_process');
+  let lijst;
+  try { lijst = execFileSync('unzip', ['-l', zip], { encoding: 'utf8' }); }
+  catch (e) { if (e.code === 'ENOENT') return; throw e; }   // geen unzip op deze machine
+  assert.match(lijst, /manifest\.json/);
+  assert.match(lijst, /posts\/zwaar\.json/);
+});
+
+test('het manifest draagt de origin, want daar hangt het behoud van AP-ids aan', () => {
+  const m = AX.buildArchive('me').manifest;
+  assert.equal(m.origin, 'https://klonkt.test');
+  assert.equal(m.formatVersion, 1);
+  assert.equal(m.actor, 'https://klonkt.test/ap/users/me');
+});
+
+test('posts van een ANDERE site komen er niet in', () => {
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s2', 'buur', 'Buur', 'u1');
+  db.prepare('INSERT INTO posts (id, site_id, slug, author_id, title, content, status) VALUES (?,?,?,?,?,?,?)')
+    .run('vanbuur', 's2', 'vanbuur', 'u1', 'Niet van mij', '<p>x</p>', 'published');
+  assert.equal(AX.buildArchive('me').files.has('posts/vanbuur.json'), false);
+});
+
+test.after(() => { try { fs.rmSync(MEDIA, { recursive: true, force: true }); } catch { /* niets */ } });
Index: test/archive-import.test.js
===================================================================
--- test/archive-import.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/archive-import.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,344 @@
+// De importer (shaer-pmr), getoetst aan het formaat in docs/EXPORT-FORMAT.md.
+//
+// De zwaarste toets is de RONDGANG: exporteer de moeilijkste post die Klonkt kan
+// maken, gooi hem weg, lees hem terug, en kijk of er nog hetzelfde staat. Een
+// exporter en een importer die elk apart kloppen maar niet op elkaar aansluiten
+// zijn samen niets waard.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-imp-'));
+process.env.MEDIA_PATH = MEDIA;
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AX = await import('../src/services/ArchiveExportService.js');
+const AI = await import('../src/services/ArchiveImportService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
+
+fs.mkdirSync(path.join(MEDIA, '2026'), { recursive: true });
+const PLAATJE = Buffer.from('bytes-van-de-cover');
+fs.writeFileSync(path.join(MEDIA, '2026', 'cover.jpg'), PLAATJE);
+
+db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, published_at,
+            created_at, updated_at, content_warning, nsfw, paid, paid_min_cents, quote_uri, quote_actor,
+            poll_json, language, tags, pinned, view_count)
+            VALUES ('zwaar','s1','zware-post','u1','Een zware post',
+            '<p>body</p><img src="/media/2026/cover.jpg" alt="cover">','de teaser','published',
+            '2026-08-01 10:00:00','2026-08-01 10:00:00','2026-08-01 12:00:00','spoiler',1,1,500,
+            'https://mstdn.social/users/x/statuses/1','https://mstdn.social/users/x',
+            '{"multiple":false,"options":[{"name":"ja"},{"name":"nee"}],"endTime":"2026-08-09T10:00:00Z"}',
+            'nl','muziek, code',1,42)`).run();
+db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, created_at)
+            VALUES ('reply','zwaar',?,?,'Anna','@anna@mstdn.social','<p>hoi</p>','2026-08-02T10:00:00Z','2026-08-02 10:00:00')`)
+  .run('https://mstdn.social/users/anna/statuses/9', 'https://mstdn.social/users/anna');
+
+const ARCHIEF = AX.buildArchive('me', { exportedAt: 'X' });
+const leeg = () => {
+  db.prepare('DELETE FROM posts').run();
+  db.prepare('DELETE FROM ap_interactions').run();
+  try { fs.rmSync(path.join(MEDIA, '2026'), { recursive: true, force: true }); } catch { /* niets */ }
+};
+
+test('de rondgang: wat eruit ging komt er hetzelfde weer in', () => {
+  leeg();
+  const r = AI.importArchive(ARCHIEF.files, { slug: 'me' });
+  assert.equal(r.posts, 1);
+  assert.equal(r.idsBehouden, true, 'zelfde origin, dus de AP-ids blijven');
+
+  const p = db.prepare('SELECT * FROM posts WHERE id = ?').get('zwaar');
+  assert.ok(p, 'het post-id is behouden -- daar wijzen de boosts elders naar');
+  assert.equal(p.title, 'Een zware post');
+  assert.equal(p.slug, 'zware-post');
+  assert.match(p.content, /<p>body<\/p>/);
+  assert.equal(p.excerpt, 'de teaser');
+  assert.equal(p.content_warning, 'spoiler');
+  assert.equal(p.nsfw, 1);
+  assert.equal(p.paid, 1);
+  assert.equal(p.paid_min_cents, 500);
+  assert.equal(p.quote_uri, 'https://mstdn.social/users/x/statuses/1');
+  assert.equal(p.language, 'nl');
+  assert.equal(p.tags, 'muziek, code');
+  assert.equal(p.pinned, 1);
+  assert.equal(p.view_count, 42);
+  assert.deepEqual(JSON.parse(p.poll_json).options.map((o) => o.name), ['ja', 'nee']);
+});
+
+test('en de media staan terug op hun oude plek, want de content wijst daarheen', () => {
+  const op = path.join(MEDIA, '2026', 'cover.jpg');
+  assert.ok(fs.existsSync(op), 'anders is de <img> in de body een dode verwijzing');
+  assert.deepEqual(fs.readFileSync(op), PLAATJE);
+});
+
+test('de antwoorden komen terug als archief', () => {
+  const r = db.prepare("SELECT * FROM ap_interactions WHERE post_id = 'zwaar'").all();
+  assert.equal(r.length, 1);
+  assert.equal(r[0].actor_name, 'Anna');
+  assert.equal(r[0].object_uri, 'https://mstdn.social/users/anna/statuses/9');
+});
+
+test('twee keer importeren levert geen dubbele posts op', () => {
+  const voor = db.prepare('SELECT COUNT(*) AS n FROM posts').get().n;
+  const r = AI.importArchive(ARCHIEF.files, { slug: 'me' });
+  assert.equal(r.overgeslagen, 1, 'de bestaande post wordt overgeslagen, niet overschreven');
+  assert.equal(r.posts, 0);
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, voor);
+});
+
+test('overschrijven gebeurt alleen als je erom vraagt', () => {
+  db.prepare("UPDATE posts SET title = 'handmatig gewijzigd' WHERE id = 'zwaar'").run();
+  AI.importArchive(ARCHIEF.files, { slug: 'me' });
+  assert.equal(db.prepare("SELECT title FROM posts WHERE id = 'zwaar'").get().title, 'handmatig gewijzigd',
+    'zonder --overwrite blijft wat er staat staan');
+  const r = AI.importArchive(ARCHIEF.files, { slug: 'me', overwrite: true });
+  assert.equal(r.overschreven, 1);
+  assert.equal(db.prepare("SELECT title FROM posts WHERE id = 'zwaar'").get().title, 'Een zware post');
+});
+
+test('een droogloop schrijft niets', () => {
+  leeg();
+  const r = AI.importArchive(ARCHIEF.files, { slug: 'me', dryRun: true });
+  assert.equal(r.posts, 1, 'hij vertelt wel wat er zou gebeuren');
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 0);
+  assert.equal(fs.existsSync(path.join(MEDIA, '2026', 'cover.jpg')), false);
+});
+
+test('een nieuwere formaatversie wordt in zijn GEHEEL geweigerd', () => {
+  // Niet half lezen: een half begrepen herstel ziet eruit alsof het gelukt is.
+  const files = new Map(ARCHIEF.files);
+  const m = JSON.parse(files.get('manifest.json').toString());
+  m.formatVersion = 99;
+  files.set('manifest.json', Buffer.from(JSON.stringify(m)));
+  assert.throws(() => AI.importArchive(files, { slug: 'me' }), /nieuwer dan deze Klonkt kent/);
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 0, 'en er is niets geschreven');
+});
+
+test('een andere origin levert NIEUWE ids op, met een waarschuwing', () => {
+  // Oude ids houden op een ander domein zou objecten publiceren onder een id dat
+  // je niet beheert -- andere servers halen dat daar op, en het is bovendien een
+  // vervalsingsoppervlak.
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const m = JSON.parse(files.get('manifest.json').toString());
+  m.origin = 'https://ergens-anders.test';
+  files.set('manifest.json', Buffer.from(JSON.stringify(m)));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.idsBehouden, false);
+  assert.match(r.waarschuwingen.join(' '), /origin verschilt/);
+  assert.equal(db.prepare("SELECT COUNT(*) AS n FROM posts WHERE id = 'zwaar'").get().n, 0);
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM posts').get().n, 1, 'wel geimporteerd, met een nieuw id');
+});
+
+test('ontbrekende media worden geteld en gemeld', () => {
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const pad = [...files.keys()].find((k) => k.startsWith('posts/'));
+  const o = JSON.parse(files.get(pad).toString());
+  o.attachment = [{ type: 'Image', mediaType: 'image/jpeg', url: 'https://oud.test/media/weg.jpg', 'shaer:availability': 'missing', 'shaer:originalUrl': 'https://oud.test/media/weg.jpg' }];
+  files.set(pad, Buffer.from(JSON.stringify(o)));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.mediaMissing, 1);
+  assert.equal(r.gemist[0].url, 'https://oud.test/media/weg.jpg');
+});
+
+test('een archief dat zijn eigen bestand mist wordt apart gemeld', () => {
+  // 'included' met een ontbrekend bestand is een KAPOT archief, geen verdwenen
+  // media. Die twee op een hoop gooien verbergt een echt probleem.
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  for (const k of [...files.keys()]) if (k.startsWith('media/')) files.delete(k);
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.mediaMissing, 0);
+  assert.match(r.waarschuwingen.join(' '), /dat er niet in zit/);
+});
+
+test('een bestand met een verkeerde checksum wordt niet weggeschreven', () => {
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const k = [...files.keys()].find((x) => x.startsWith('media/'));
+  files.set(k, Buffer.from('iets heel anders'));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.match(r.waarschuwingen.join(' '), /checksum klopt niet/);
+  assert.equal(fs.existsSync(path.join(MEDIA, '2026', 'cover.jpg')), false);
+});
+
+test('een antwoordenbundel zonder archiefmarkering wordt overgeslagen', () => {
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const pad = [...files.keys()].find((k) => k.startsWith('replies/'));
+  const c = JSON.parse(files.get(pad).toString());
+  delete c['shaer:archive'];
+  files.set(pad, Buffer.from(JSON.stringify(c)));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.replies, 0);
+  assert.match(r.waarschuwingen.join(' '), /niet gemarkeerd als archief/);
+});
+
+test('een pad buiten de media-map wordt niet geschreven', () => {
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  const pad = [...files.keys()].find((k) => k.startsWith('posts/'));
+  const o = JSON.parse(files.get(pad).toString());
+  const mk = [...files.keys()].find((k) => k.startsWith('media/'));
+  o.attachment = [{ type: 'Image', mediaType: 'image/jpeg', url: mk, 'shaer:availability': 'included', 'shaer:originalUrl': '/media/../../../tmp/klonkt-inbraak' }];
+  files.set(pad, Buffer.from(JSON.stringify(o)));
+  AI.importArchive(files, { slug: 'me' });
+  assert.equal(fs.existsSync('/tmp/klonkt-inbraak'), false);
+});
+
+test('readable/ wordt nooit gelezen', () => {
+  // Dat is de hele reden dat het afgeleid is: twee bronnen die allebei gelezen
+  // worden gaan uiteenlopen.
+  leeg();
+  const files = new Map(ARCHIEF.files);
+  for (const k of [...files.keys()]) if (k.startsWith('readable/')) files.set(k, Buffer.from('ONZIN'));
+  const r = AI.importArchive(files, { slug: 'me' });
+  assert.equal(r.posts, 1);
+  assert.match(db.prepare("SELECT content FROM posts WHERE id = 'zwaar'").get().content, /<p>body<\/p>/);
+});
+
+test('de zip-lezer haalt hetzelfde eruit als de map-lezer', () => {
+  const zip = path.join(MEDIA, 'rond.zip');
+  fs.writeFileSync(zip, AX.zipArchive(ARCHIEF.files));
+  const uit = AI.readArchive(zip);
+  assert.deepEqual([...uit.keys()].sort(), [...ARCHIEF.files.keys()].sort());
+  for (const k of uit.keys()) assert.deepEqual(uit.get(k), ARCHIEF.files.get(k), `${k} verschilt`);
+});
+
+test('een GECOMPRIMEERDE zip van elders wordt ook gelezen', async () => {
+  // Onze eigen zip is store-only. Zou de lezer alleen dat aankunnen, dan is het
+  // geen uitwisselformaat maar een privé-doosje.
+  const zlib = await import('zlib');
+  const inhoud = Buffer.from('{"formatVersion":1,"origin":"x"}');
+  const naam = Buffer.from('manifest.json', 'utf8');
+  const comp = zlib.deflateRawSync(inhoud);
+  const crc = (() => {
+    let c = -1;
+    for (const b of inhoud) { c ^= b; for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xEDB88320 : c >>> 1; }
+    return (c ^ -1) >>> 0;
+  })();
+  const lh = Buffer.alloc(30);
+  lh.writeUInt32LE(0x04034b50, 0); lh.writeUInt16LE(20, 4); lh.writeUInt16LE(8, 8);
+  lh.writeUInt32LE(crc, 14); lh.writeUInt32LE(comp.length, 18); lh.writeUInt32LE(inhoud.length, 22);
+  lh.writeUInt16LE(naam.length, 26);
+  const ch = Buffer.alloc(46);
+  ch.writeUInt32LE(0x02014b50, 0); ch.writeUInt16LE(8, 10);
+  ch.writeUInt32LE(crc, 16); ch.writeUInt32LE(comp.length, 20); ch.writeUInt32LE(inhoud.length, 24);
+  ch.writeUInt16LE(naam.length, 28); ch.writeUInt32LE(0, 42);
+  const eocd = Buffer.alloc(22);
+  eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(1, 8); eocd.writeUInt16LE(1, 10);
+  eocd.writeUInt32LE(46 + naam.length, 12); eocd.writeUInt32LE(30 + naam.length + comp.length, 16);
+  const zip = path.join(MEDIA, 'deflate.zip');
+  fs.writeFileSync(zip, Buffer.concat([lh, naam, comp, ch, naam, eocd]));
+  assert.deepEqual(AI.readArchiveZip(fs.readFileSync(zip)).get('manifest.json'), inhoud);
+});
+
+test('een gehoste track overleeft de rondgang, met zijn bestand erbij', () => {
+  leeg();
+  db.prepare('DELETE FROM audio_tracks').run();
+  fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
+  const WAV = Buffer.from('nep-audio-bytes');
+  fs.writeFileSync(path.join(MEDIA, 'audio', 't1.mp3'), WAV);
+  db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
+    .run('m1', 's1', 't1.mp3', 'audio/mpeg', WAV.length, path.join(MEDIA, 'audio', 't1.mp3'));
+  db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, duration, media_id, credit, license)
+              VALUES ('tr1','s1','Kanonnen','Robin',212,'m1','opname 2026','CC BY')`).run();
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at)
+              VALUES ('audio','s1','met-audio','u1','Met audio','<p>luister</p>[[track:tr1]]','published','2026-08-03 10:00:00')`).run();
+
+  const arch = AX.buildArchive('me', { exportedAt: 'X' });
+  db.prepare('DELETE FROM posts').run();
+  db.prepare('DELETE FROM audio_tracks').run();
+  db.prepare('DELETE FROM media').run();
+  fs.rmSync(path.join(MEDIA, 'audio'), { recursive: true, force: true });
+
+  AI.importArchive(arch.files, { slug: 'me' });
+  const t = db.prepare("SELECT * FROM audio_tracks WHERE id = 'tr1'").get();
+  assert.ok(t, '[[track:tr1]] in de content valt anders op niets terug');
+  assert.equal(t.title, 'Kanonnen');
+  assert.equal(t.license, 'CC BY');
+  const m = db.prepare('SELECT * FROM media WHERE id = ?').get(t.media_id);
+  assert.ok(m && fs.existsSync(m.storage_path), 'en het bestand staat er weer');
+  assert.deepEqual(fs.readFileSync(m.storage_path), WAV);
+});
+
+test('een vroegtijdig gesloten poll blijft gesloten', () => {
+  // Gevonden bij de rondgang op echte beta-data: poll_json.closed viel weg, dus
+  // een poll die je met een einddatum in de TOEKOMST had gesloten stond na een
+  // herstel weer open. AS2 kent hiervoor `closed` op een Question.
+  leeg();
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at, poll_json)
+              VALUES ('dicht','s1','dichte-poll','u1','Dicht','<p>x</p>','published','2026-08-04 10:00:00',
+              '{"multiple":false,"options":[{"name":"a"},{"name":"b"}],"endTime":"2030-01-01T00:00:00Z","closed":true}')`).run();
+  const arch = AX.buildArchive('me', { exportedAt: 'X' });
+  assert.equal(JSON.parse(arch.files.get('posts/dicht.json').toString()).closed, true);
+  db.prepare('DELETE FROM posts').run();
+  AI.importArchive(arch.files, { slug: 'me' });
+  const d = JSON.parse(db.prepare("SELECT poll_json FROM posts WHERE id = 'dicht'").get().poll_json);
+  assert.equal(d.closed, true, 'anders staat een gesloten stemming na een herstel weer open');
+  assert.equal(d.endTime, '2030-01-01T00:00:00Z');
+});
+
+test('de cover en de speler komen terug, niet alleen hun bestanden', () => {
+  // Gevonden bij het oefenherstel, door ALLE kolommen te vergelijken in plaats
+  // van een handjevol: de bytes zaten in het archief, maar nergens stond dat ze
+  // de cover waren. De post kwam zonder cover en zonder speler terug.
+  leeg();
+  fs.mkdirSync(path.join(MEDIA, 'c'), { recursive: true });
+  for (const [n, b] of [['cov.jpg', 'cover'], ['op.m4a', 'audio'], ['op.png', 'poster']]) {
+    fs.writeFileSync(path.join(MEDIA, 'c', n), Buffer.from(b));
+  }
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at, cover_image_url, cover_alt, c2s_attachments)
+              VALUES ('rijk','s1','rijke-post','u1','Rijk','<p>x</p>','published','2026-08-05 10:00:00',
+              '/media/c/cov.jpg','de cover', ?)`)
+    .run(JSON.stringify([{ url: '/media/c/op.m4a', mediaType: 'audio/mp4', name: 'opname.m4a', poster: '/media/c/op.png' }]));
+
+  const arch = AX.buildArchive('me', { exportedAt: 'X' });
+  const rollen = JSON.parse(arch.files.get('posts/rijk.json').toString()).attachment.map((a) => a['shaer:role']);
+  assert.deepEqual(rollen.sort(), ['c2s', 'cover', 'poster'], 'elke bijlage zegt waar hij voor was');
+
+  db.prepare('DELETE FROM posts').run();
+  fs.rmSync(path.join(MEDIA, 'c'), { recursive: true, force: true });
+  AI.importArchive(arch.files, { slug: 'me' });
+
+  const p = db.prepare("SELECT * FROM posts WHERE id = 'rijk'").get();
+  assert.equal(p.cover_image_url, '/media/c/cov.jpg', 'zonder dit staat de post zonder cover terug');
+  assert.equal(p.cover_alt, 'de cover');
+  const c2s = JSON.parse(p.c2s_attachments);
+  assert.equal(c2s[0].url, '/media/c/op.m4a');
+  assert.equal(c2s[0].poster, '/media/c/op.png', 'de poster hoort weer bij zijn opname');
+  assert.ok(fs.existsSync(path.join(MEDIA, 'c', 'cov.jpg')));
+});
+
+test('het MOMENT van een tijdstempel overleeft, ook uit SQL-notatie', () => {
+  // Klonkt schrijft in twee spellingen. Het archief normaliseert naar ISO; het
+  // moment blijft, de spelling niet. Dat is gedocumenteerd gedrag, geen verlies.
+  leeg();
+  db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, status, published_at)
+              VALUES ('tijd','s1','tijd','u1','T','<p>x</p>','published','2026-07-01 12:56:10')`).run();
+  const arch = AX.buildArchive('me', { exportedAt: 'X' });
+  db.prepare('DELETE FROM posts').run();
+  AI.importArchive(arch.files, { slug: 'me' });
+  const p = db.prepare("SELECT published_at FROM posts WHERE id = 'tijd'").get();
+  assert.equal(Date.parse(p.published_at), Date.parse('2026-07-01T12:56:10Z'));
+});
+
+test('zonder manifest is het geen archief', () => {
+  const files = new Map(ARCHIEF.files);
+  files.delete('manifest.json');
+  assert.throws(() => AI.importArchive(files, { slug: 'me' }), /geen manifest/);
+});
+
+test.after(() => { try { fs.rmSync(MEDIA, { recursive: true, force: true }); } catch { /* niets */ } });
Index: test/archive-recovery.test.js
===================================================================
--- test/archive-recovery.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/archive-recovery.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,255 @@
+// Herstel uit andermans tijdlijn-cache (shaer-l1v).
+//
+// De opzet bootst de boiert-situatie na: een verloren site waarvan de posts nog
+// in de ap_timeline van een volgende instance staan, en waarvan de mediamap de
+// ramp wel heeft overleefd.
+//
+// De zwaarste toets is de KETTING: cache -> herstelarchief -> importer -> posts.
+// Er is met opzet geen apart herstelpad; als die ketting niet sluit is het hele
+// idee van een gedeeld formaat niets waard.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import Database from 'better-sqlite3';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://boiert.eu';
+const MEDIA = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-herstel-'));
+process.env.MEDIA_PATH = MEDIA;
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AR = await import('../src/services/ArchiveRecoveryService.js');
+const AI = await import('../src/services/ArchiveImportService.js');
+
+const ACTOR = 'https://boiert.eu/ap/users/boiert';
+const werk = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-bron-'));
+
+// De GEREDDE mediamap van de verloren site: de database is weg, de bestanden niet.
+const GERED = path.join(werk, 'gered-media');
+fs.mkdirSync(path.join(GERED, 'post-images'), { recursive: true });
+const COVER = Buffer.from('bytes-van-de-cover-van-boiert');
+fs.writeFileSync(path.join(GERED, 'post-images', 'cover.webp'), COVER);
+
+/** Een tijdlijn-cache van een instance die boiert volgt. */
+function maakBron(naam, rijen) {
+  const p = path.join(werk, naam);
+  const s = new Database(p);
+  s.exec(`CREATE TABLE ap_timeline (id TEXT NOT NULL, slug TEXT NOT NULL, author_uri TEXT, author_name TEXT,
+          author_handle TEXT, author_icon TEXT, author_url TEXT, content TEXT, url TEXT, published TEXT,
+          media_json TEXT, created_at DATETIME, boosted INTEGER, liked INTEGER, nsfw INTEGER, cw TEXT,
+          reblog_name TEXT, poll_json TEXT, quote_json TEXT, emoji_json TEXT, embed_json TEXT, UNIQUE(slug, id))`);
+  const ins = s.prepare(`INSERT INTO ap_timeline (id, slug, author_uri, content, url, published, media_json, nsfw, cw, poll_json, quote_json)
+                         VALUES (@id,'volger',@author_uri,@content,@url,@published,@media_json,@nsfw,@cw,@poll_json,@quote_json)`);
+  for (const r of rijen) {
+    ins.run({
+      author_uri: ACTOR, content: '', url: null, published: null, media_json: null,
+      nsfw: 0, cw: null, poll_json: null, quote_json: null, ...r,
+    });
+  }
+  s.close();
+  return p;
+}
+
+const BRON = maakBron('sound-fabrics.sqlite', [
+  {
+    id: 'https://boiert.eu/ap/notes/post-1',
+    content: '<p><strong>Back to 1987!</strong></p><p>Rick Astley in een AI-cover.</p>',
+    url: 'https://boiert.eu/waiting-on-you',
+    published: '2026-04-24T06:32:45.000Z',
+    media_json: JSON.stringify([{ url: 'https://boiert.eu/media/post-images/cover.webp', type: 'image/webp', name: 'de hoes' }]),
+  },
+  {
+    id: 'https://boiert.eu/ap/notes/post-2',
+    content: '<p>Een korte post zonder titel.</p>',
+    url: 'https://boiert.eu/kort',
+    published: '2026-05-01T10:00:00.000Z',
+    cw: 'spoiler', nsfw: 1,
+  },
+  {
+    id: 'https://boiert.eu/ap/notes/post-3',
+    content: '<p><strong>Stemming</strong></p><p>Wat wordt het?</p>',
+    url: 'https://boiert.eu/stemming',
+    published: '2026-05-02T10:00:00.000Z',
+    poll_json: JSON.stringify({ multiple: false, options: [{ name: 'ja', count: 3 }, { name: 'nee', count: 1 }], endTime: '2026-05-09T10:00:00Z', closed: true }),
+  },
+  // Iemand ANDERS in dezelfde tijdlijn: die hoort er niet in.
+  { id: 'https://elders.test/ap/notes/x', author_uri: 'https://elders.test/users/iemand', content: '<p>niet van boiert</p>', url: 'https://elders.test/x' },
+]);
+
+// Een tweede volger die verder terug gaat: de vereniging dekt meer dan een bron.
+const BRON2 = maakBron('tweede.sqlite', [
+  {
+    id: 'https://boiert.eu/ap/notes/post-0',
+    content: '<p><strong>De oudste</strong></p><p>van voor de andere volger.</p>',
+    url: 'https://boiert.eu/de-oudste',
+    published: '2026-01-05T09:00:00.000Z',
+  },
+  // Dezelfde post als in bron 1, maar afgekapt: de RIJKSTE versie hoort te winnen.
+  {
+    id: 'https://boiert.eu/ap/notes/post-1',
+    content: '<p><strong>Back to 1987!</strong></p>',
+    url: 'https://boiert.eu/waiting-on-you',
+    published: '2026-04-24T06:32:45.000Z',
+  },
+]);
+
+const herstel = (extra = {}) => AR.recoverFromCache({
+  sources: [BRON, BRON2], actorUri: ACTOR, mediaRoot: GERED, exportedAt: 'X', ...extra,
+});
+
+test('alleen de posts van de verloren actor komen mee', () => {
+  const { rapport, files } = herstel();
+  assert.equal(rapport.posts, 4);
+  assert.equal(files.has('posts/x.json'), false, 'andermans post hoort er niet in');
+  assert.ok(files.has('posts/post-1.json'));
+});
+
+test('de AP-ids blijven staan -- daar hangt het hele herstel aan', () => {
+  const { files, manifest } = herstel();
+  const o = JSON.parse(files.get('posts/post-1.json').toString());
+  assert.equal(o.id, 'https://boiert.eu/ap/notes/post-1');
+  assert.equal(manifest.origin, 'https://boiert.eu', 'anders weigert de importer de ids te behouden');
+});
+
+test('de titel wordt uit de tekst gevist en de rest blijft heel', () => {
+  const o = JSON.parse(herstel().files.get('posts/post-1.json').toString());
+  assert.equal(o.name, 'Back to 1987!');
+  assert.equal(o.type, 'Article');
+  assert.equal(o.content, '<p>Rick Astley in een AI-cover.</p>');
+  assert.equal(o['shaer:slug'], 'waiting-on-you', 'de slug komt uit de permalink, niet uit de titel');
+});
+
+test('een post zonder vetgedrukte openingsregel houdt zijn hele tekst', () => {
+  const o = JSON.parse(herstel().files.get('posts/post-2.json').toString());
+  assert.equal(o.name, undefined);
+  assert.equal(o.type, 'Note');
+  assert.equal(o.content, '<p>Een korte post zonder titel.</p>');
+  assert.equal(o.summary, 'spoiler');
+  assert.equal(o.sensitive, true);
+});
+
+test('met --houd-titel blijft de tekst onaangeroerd', () => {
+  // Er is geen sluitend signaal dat een vetgedrukte eerste regel een titel was,
+  // dus er moet een uitweg zijn.
+  const o = JSON.parse(herstel({ houdTitelInTekst: true }).files.get('posts/post-1.json').toString());
+  assert.equal(o.name, undefined);
+  assert.match(o.content, /<strong>Back to 1987!<\/strong>/);
+});
+
+test('de media komen van de geredde schijf, niet uit de cache', () => {
+  // De cache bewaart alleen URL's. De bytes overleefden de ramp los van de
+  // database, en het URL-pad is de sleutel ertussen.
+  const { files, rapport } = herstel();
+  const a = JSON.parse(files.get('posts/post-1.json').toString()).attachment[0];
+  assert.equal(a['shaer:availability'], 'included');
+  assert.equal(a['shaer:role'], 'cover');
+  assert.deepEqual(files.get(a.url), COVER);
+  assert.equal(rapport.media, 1);
+});
+
+test('zonder geredde mediamap wordt het gat benoemd, niet verzwegen', () => {
+  const { files, rapport } = herstel({ mediaRoot: null });
+  const a = JSON.parse(files.get('posts/post-1.json').toString()).attachment[0];
+  assert.equal(a['shaer:availability'], 'missing');
+  assert.equal(a['shaer:originalUrl'], 'https://boiert.eu/media/post-images/cover.webp');
+  assert.equal(rapport.mediaMissing, 1);
+});
+
+test('een poll overleeft, inclusief dat hij gesloten was', () => {
+  const o = JSON.parse(herstel().files.get('posts/post-3.json').toString());
+  assert.equal(o.type, 'Question');
+  assert.deepEqual(o.oneOf.map((x) => x.name), ['ja', 'nee']);
+  assert.equal(o.closed, true);
+  assert.equal(o.endTime, '2026-05-09T10:00:00Z');
+});
+
+test('twee bronnen samen dekken meer, en de rijkste versie wint', () => {
+  const { files, rapport } = herstel();
+  assert.ok(files.has('posts/post-0.json'), 'alleen de tweede volger had deze');
+  assert.match(JSON.parse(files.get('posts/post-1.json').toString()).content, /Rick Astley/,
+    'de afgekapte versie uit de tweede bron mag de volledige niet overschrijven');
+  assert.equal(rapport.bronnen.length, 2);
+});
+
+test('het manifest zegt dat dit GERECONSTRUEERD is en wat er mist', () => {
+  // Anders ziet iemand dit over een jaar aan voor een gewone export en denkt hij
+  // dat de site compleet is.
+  const m = herstel().manifest;
+  assert.equal(m['shaer:recovered'].from, 'timeline-cache');
+  assert.deepEqual(m['shaer:recovered'].window, { oldest: '2026-01-05T09:00:00.000Z', newest: '2026-05-02T10:00:00.000Z' });
+  assert.match(m['shaer:recovered'].missing.join(' '), /replies/);
+});
+
+test('DE KETTING: cache -> archief -> importer -> posts', () => {
+  // Bewust geen eigen herstelpad. Zou dit niet sluiten, dan was het gedeelde
+  // formaat een papieren afspraak.
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run('u1', 'boiert', 'b@test', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'boiert', 'Boiert', 'u1');
+
+  const r = AI.importArchive(herstel().files, { slug: 'boiert' });
+  assert.equal(r.idsBehouden, true, 'zelfde origin, dus de boosts elders vinden hun post terug');
+  assert.equal(r.posts, 4);
+
+  const p = db.prepare("SELECT * FROM posts WHERE id = 'post-1'").get();
+  assert.ok(p, 'het oorspronkelijke post-id staat er weer');
+  assert.equal(p.title, 'Back to 1987!');
+  assert.equal(p.slug, 'waiting-on-you');
+  assert.equal(p.status, 'published');
+  assert.equal(p.cover_image_url, '/media/post-images/cover.webp');
+  assert.deepEqual(fs.readFileSync(path.join(MEDIA, 'post-images', 'cover.webp')), COVER);
+
+  const q = db.prepare("SELECT poll_json FROM posts WHERE id = 'post-3'").get();
+  assert.equal(JSON.parse(q.poll_json).closed, true);
+});
+
+test('gehoste audio wordt ook teruggevonden, van een ANDERE map', () => {
+  // Op echte cachedata bleek een deel van de bijlagen niet via /media/ te lopen
+  // maar via /audio/stream/<bestandsnaam>, en dat is AUDIO_DIR -- een andere map
+  // naast de mediamap. Zonder dit blijft de halve muziekcatalogus liggen.
+  const AUDIO = path.join(werk, 'gered-audio');
+  fs.mkdirSync(AUDIO, { recursive: true });
+  const MP3 = Buffer.from('nep-mp3-bytes');
+  fs.writeFileSync(path.join(AUDIO, 'track.mp3'), MP3);
+  const bron = maakBron('audio.sqlite', [{
+    id: 'https://boiert.eu/ap/notes/post-audio',
+    content: '<p>luister</p>',
+    url: 'https://boiert.eu/luister',
+    published: '2026-06-01T10:00:00.000Z',
+    media_json: JSON.stringify([{ url: 'https://boiert.eu/audio/stream/track.mp3', type: 'audio/mpeg' }]),
+  }]);
+  const uit = AR.recoverFromCache({ sources: [bron], actorUri: ACTOR, mediaRoot: GERED, audioRoot: AUDIO, exportedAt: 'X' });
+  const a = JSON.parse(uit.files.get('posts/post-audio.json').toString()).attachment[0];
+  assert.equal(a['shaer:availability'], 'included');
+  assert.deepEqual(uit.files.get(a.url), MP3);
+
+  // En het moet ook de import overleven: het bestand hoort ergens te landen EN
+  // vanuit de kolommen vindbaar te zijn, niet alleen weggeschreven.
+  const r = AI.importArchive(uit.files, { slug: 'boiert' });
+  assert.equal(r.media, 1);
+  const p = db.prepare("SELECT c2s_attachments, cover_image_url FROM posts WHERE id = 'post-audio'").get();
+  const plek = p.cover_image_url;
+  assert.ok(plek && plek.startsWith('/media/'), `de bijlage hoort een plek te hebben, kreeg ${plek}`);
+  assert.ok(fs.existsSync(path.join(MEDIA, plek.slice('/media/'.length))), 'en daar hoort het bestand te staan');
+});
+
+test('zonder actor weigert hij te beginnen', () => {
+  assert.throws(() => AR.recoverFromCache({ sources: [BRON] }), /actorUri is verplicht/);
+});
+
+test('een onleesbare bron stopt het herstel niet, maar wordt wel gemeld', () => {
+  const { rapport } = AR.recoverFromCache({ sources: [BRON, path.join(werk, 'bestaat-niet.sqlite')], actorUri: ACTOR, exportedAt: 'X' });
+  assert.ok(rapport.posts > 0);
+  assert.match(rapport.waarschuwingen.join(' '), /niet te openen/);
+});
+
+test.after(() => {
+  for (const d of [MEDIA, werk]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* niets */ } }
+});
Index: test/authorized-fetch.test.js
===================================================================
--- test/authorized-fetch.test.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ test/authorized-fetch.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -1,23 +1,109 @@
-// FEP-633c §5.3 note: a committed guardian is recognised for authorized fetch.
+// Authorized fetch (shaer-afq): een instance in Mastodons secure mode geeft zijn
+// actor-document -- en dus zijn publieke sleutel -- alleen aan een ONDERTEKEND
+// verzoek. Zonder die handtekening kregen we 401, vonden we geen sleutel, en
+// wezen we elke correct ondertekende Follow van die instance af. Op boiert.eu
+// bleven daardoor vier accounts eindeloos hangen.
+//
+// De fetch is hier gestubd: het gaat om de vraag OF er ondertekend wordt en of
+// er wordt teruggevallen, niet om echte HTTP of crypto.
+//
+// Run: npm test
+
 import { test } from 'node:test';
 import assert from 'node:assert/strict';
 
 process.env.DATABASE_PATH = ':memory:';
-process.env.PUBLIC_BASE_URL = 'https://test.example';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
 
 const dbMod = await import('../src/config/database.js');
 const db = dbMod.default;
 dbMod.initializeDatabase();
-const AP = (await import('../src/services/ActivityPubService.js')).default;
+const AP = await import('../src/services/ActivityPubService.js');
 
-db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@t', 'x', 'god');
-db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,1)').run('s1', 'kid', 'kid', 'u1');
-// Commit a guardian relation: kid (ward) is guarded by mom.
-const MOM = 'https://mom.example/ap/users/mom';
-db.prepare("INSERT INTO ap_guardianships (slug, role, other_uri, status, created_at) VALUES ('kid','ward',?, 'accepted', CURRENT_TIMESTAMP)").run(MOM);
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
 
-test('a committed guardian is recognised; a stranger is not', () => {
-  assert.equal(AP.isWardGuardian('kid', MOM), true);
-  assert.equal(AP.isWardGuardian('kid', 'https://x.example/ap/users/stranger'), false);
-  assert.equal(AP.isWardGuardian('nosuch', MOM), false);
+// IP-literals: safeFetch slaat de DNS-lookup over, dus de test blijft offline.
+const OPEN_ACTOR = 'https://203.0.113.30/users/open';       // gewone instance
+const SECURE_ACTOR = 'https://203.0.113.40/users/gesloten'; // authorized fetch
+
+let verzoeken = [];
+const echteFetch = globalThis.fetch;
+globalThis.fetch = async (url, opts = {}) => {
+  const u = String(url);
+  const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
+  verzoeken.push({ url: u, ondertekend });
+  const doc = (id) => new Response(JSON.stringify({
+    id, type: 'Person', preferredUsername: 'x', inbox: `${id}/inbox`,
+    publicKey: { id: `${id}#main-key`, owner: id, publicKeyPem: '-----BEGIN PUBLIC KEY-----\nx\n-----END PUBLIC KEY-----' },
+  }), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+
+  if (u === OPEN_ACTOR) return doc(OPEN_ACTOR);
+  // De kern van secure mode: onbetekend is het 401, ondertekend krijg je hem wel.
+  if (u === SECURE_ACTOR) return ondertekend ? doc(SECURE_ACTOR) : new Response('unauthorized', { status: 401 });
+  return new Response('not found', { status: 404 });
+};
+
+test('een actor achter authorized fetch wordt nu wél opgehaald', async () => {
+  verzoeken = [];
+  const actor = await AP.fetchActor(SECURE_ACTOR, { asSlug: 'me' });
+  assert.ok(actor, 'de actor hoort binnen te komen');
+  assert.equal(actor.id, SECURE_ACTOR);
+  assert.ok(verzoeken.some((v) => v.url === SECURE_ACTOR && v.ondertekend), 'het verzoek hoort ondertekend te zijn');
 });
+
+test('zonder ondertekenaar blijft dezelfde actor onbereikbaar', async () => {
+  // Dit is precies het oude gedrag, en het bewijst dat de stub echt onderscheid
+  // maakt in plaats van altijd mee te werken.
+  const actor = await AP.fetchActor(SECURE_ACTOR);
+  assert.equal(actor, null);
+});
+
+test('een gewone instance blijft werken, ondertekend of niet', async () => {
+  assert.ok(await AP.fetchActor(OPEN_ACTOR), 'onbetekend');
+  assert.ok(await AP.fetchActor(OPEN_ACTOR, { asSlug: 'me' }), 'ondertekend');
+});
+
+test('een OPEN instance wordt NIET ondertekend opgehaald', () => {
+  // De veiligheidskant van shaer-afq: verifyRequest haalt de keyId-URL op
+  // voordat er iets geverifieerd is, en die URL komt uit een header die iedereen
+  // mag sturen. Tekenden we standaard, dan kan een vreemde ons een ondertekend
+  // verzoek naar een adres van zijn keuze laten sturen, met onze identiteit
+  // eronder. Onbetekend eerst dus, en alleen tekenen als het anders niet lukt.
+  verzoeken = [];
+  return AP.fetchActor(OPEN_ACTOR, { asSlug: 'me' }).then((actor) => {
+    assert.ok(actor);
+    assert.equal(verzoeken.length, 1, 'één poging, geen tweede');
+    assert.equal(verzoeken[0].ondertekend, false, 'en die was onbetekend');
+  });
+});
+
+test('een document zonder sleutel telt als mislukt en leidt tot een ondertekende poging', async () => {
+  // Sommige instances serveren onbetekend wel iets, maar zonder publicKey. Voor
+  // een verificatie hebben we daar niets aan.
+  const KAAL = 'https://203.0.113.50/users/kaal';
+  const stubOrig = globalThis.fetch;
+  globalThis.fetch = async (url, opts = {}) => {
+    const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
+    if (String(url) === KAAL) {
+      verzoeken.push({ url: String(url), ondertekend });
+      const body = ondertekend
+        ? { id: KAAL, type: 'Person', publicKey: { id: `${KAAL}#k`, owner: KAAL, publicKeyPem: 'x' } }
+        : { id: KAAL, type: 'Person' };   // kaal: geen sleutel
+      return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+    }
+    return stubOrig(url, opts);
+  };
+  verzoeken = [];
+  const actor = await AP.fetchActor(KAAL, { asSlug: 'me' });
+  globalThis.fetch = stubOrig;
+  assert.ok(actor.publicKey && actor.publicKey.publicKeyPem, 'de ondertekende poging levert de sleutel');
+  assert.deepEqual(verzoeken.map((v) => v.ondertekend), [false, true], 'eerst onbetekend, daarna pas ondertekend');
+});
+
+test('een onbekende actor blijft null, ondertekend of niet', async () => {
+  assert.equal(await AP.fetchActor('https://203.0.113.99/users/weg', { asSlug: 'me' }), null);
+});
+
+test.after(() => { globalThis.fetch = echteFetch; });
Index: test/co-location.test.js
===================================================================
--- test/co-location.test.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ test/co-location.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -227,6 +227,19 @@
     // of that job being done.
     wardSlugsOf: 'TODO: gated follows still take the shared-database path for a local guardian',
+    // Tweede plek van diezelfde sluiproute, aangekomen met Fase 2 (shaer-jdb):
+    // followsCollection vult de wachtrij voor een ward op DEZE instance uit
+    // ap_pending_follows. Hoort mee te verdwijnen met wardSlugsOf hierboven --
+    // niet apart, want het is een sluiproute en geen tweede probleem.
+    slugOf: 'TODO: idem, nu ook in de follows-wachtrij (shaer-h6u)',
   };
-  const files = ['src/services/guardianship/handshake.js', 'src/routes/guardian.js'];
+  // De hele module, niet twee bestanden. Het commentaar hierboven zegt "elke
+  // plek die vraagt of deze actor van ons is", en dat werd tot nu toe afgemeten
+  // aan twee namen -- waardoor een nieuwe sluiproute in een DERDE bestand er
+  // geruisloos doorheen kwam. Dat is precies hoe deze er kwam.
+  const files = [
+    ...fs.readdirSync('src/services/guardianship').filter((f) => f.endsWith('.js'))
+      .map((f) => `src/services/guardianship/${f}`),
+    'src/routes/guardian.js',
+  ];
   // Only top-level declarations name a scope; an indented `const base = ...` is
   // a local and would otherwise take the blame for its enclosing function. A
Index: test/feed-wait.test.js
===================================================================
--- test/feed-wait.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/feed-wait.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,196 @@
+// Wachten op nieuws, als uitbreiding van de inbox-lezing (shaer-n05).
+//
+// Geen tweede endpoint en geen seintje-formaat: dezelfde lezing, die desgevraagd
+// even blijft hangen. Wat hier bewaakt wordt is de MERKSTEEN -- als die niet
+// beweegt bij iets dat de inbox wel zou tonen, blijft een wachtende client
+// slapen terwijl er nieuws is. Dat is erger dan niet wachten, want het lijkt te
+// werken.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@t', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s2', 'buur', 'Buur', 'u1');
+db.prepare('INSERT INTO posts (id, site_id, slug, author_id, title, content, status) VALUES (?,?,?,?,?,?,?)')
+  .run('p1', 's1', 'mijn-post', 'u1', 'Post', '<p>x</p>', 'published');
+
+let n = 0;
+const nieuwePost = (slug = 'me') => db.prepare(
+  "INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES (?,?,'https://r.test/u/a','<p>x</p>')",
+).run(`https://r.test/n/${++n}`, slug);
+
+test('een verse tijdlijnpost beweegt de merksteen', () => {
+  const voor = AP.feedCursor('me');
+  nieuwePost();
+  assert.notEqual(AP.feedCursor('me'), voor);
+});
+
+test('en zo ook de andere drie poten van de inbox', () => {
+  // De inbox voegt vier bronnen samen. Ontbreekt er een in de merksteen, dan
+  // wordt een client niet wakker van precies dat soort nieuws.
+  const bericht = () => db.prepare(
+    "INSERT INTO ap_mentions (slug, object_uri, actor_uri, content) VALUES ('me',?, 'https://r.test/u/a','<p>hoi</p>')",
+  ).run(`https://r.test/m/${++n}`);
+  const antwoord = () => db.prepare(
+    `INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, content)
+     VALUES ('reply','p1',?, 'https://r.test/u/a','<p>hoi</p>')`,
+  ).run(`https://r.test/r/${++n}`);
+  const verstuurd = () => db.prepare(
+    "INSERT INTO ap_outbox (id, site_slug, post_id, content) VALUES (?,'me','p1','<p>x</p>')",
+  ).run(`o${++n}`);
+
+  for (const [naam, doen] of [['bericht', bericht], ['antwoord', antwoord], ['verstuurd', verstuurd]]) {
+    const voor = AP.feedCursor('me');
+    doen();
+    assert.notEqual(AP.feedCursor('me'), voor, `${naam} hoort de merksteen te bewegen`);
+  }
+});
+
+test('een BEWERKING beweegt hem ook', () => {
+  // De reden dat de merksteen niet meer op MAX(rowid) leunt. Een Update schrijft
+  // dezelfde rij, dus de rowid bleef staan en een wachtende client sliep door.
+  db.prepare("INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES ('bw','me','https://r.test/u/a','<p>oud</p>')").run();
+  const voor = AP.feedCursor('me');
+  db.prepare("UPDATE ap_timeline SET content = '<p>nieuw</p>' WHERE id = 'bw'").run();
+  assert.notEqual(AP.feedCursor('me'), voor);
+});
+
+test('en een VERWIJDERING, ook als het niet de laatste is', () => {
+  db.prepare("INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES ('later','me','https://r.test/u/a','<p>x</p>')").run();
+  const voor = AP.feedCursor('me');
+  db.prepare("DELETE FROM ap_timeline WHERE id = 'bw'").run();
+  assert.notEqual(AP.feedCursor('me'), voor, 'anders blijft een verwijderde post staan tot je toevallig ververst');
+});
+
+test('maar een LIKE of BOOST van jezelf niet', () => {
+  // De val waar dit bijna in liep: een like schrijft ap_timeline.liked en een 🔁
+  // schrijft .boosted. Zonder de UPDATE OF-kolomlijst zou je eigen like het
+  // bericht als BEWERKT merken en elke wachtende client wekken.
+  const voor = AP.feedCursor('me');
+  db.prepare("UPDATE ap_timeline SET liked = 1 WHERE id = 'later'").run();
+  db.prepare("UPDATE ap_timeline SET boosted = 1 WHERE id = 'later'").run();
+  db.prepare("UPDATE ap_interactions SET acted_like = 1 WHERE post_id = 'p1'").run();
+  assert.equal(AP.feedCursor('me'), voor, 'je eigen reactie is geen nieuws');
+});
+
+test('een INSERT OR IGNORE die niets doet beweegt hem niet', () => {
+  const voor = AP.feedCursor('me');
+  db.prepare("INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, content) VALUES ('later','me','https://r.test/u/a','<p>x</p>')").run();
+  assert.equal(AP.feedCursor('me'), voor);
+});
+
+test('de teller loopt nooit achteruit', () => {
+  // Met MAX(rowid) zakte hij terug zodra de nieuwste rij verdween, en dan denkt
+  // een client dat er niets gebeurd is.
+  const hoog = parseInt(AP.feedCursor('me'), 10);
+  db.prepare('DELETE FROM ap_timeline WHERE slug = ?').run('me');
+  assert.ok(parseInt(AP.feedCursor('me'), 10) >= hoog);
+});
+
+test('en hij vertelt WAT er veranderd is', () => {
+  // Dit is wat een revisieteller niet kan en deze tabel gratis meegeeft: de
+  // "bewerkt"-markering hoeft er later geen eigen bouwsel voor te worden.
+  const stand = (uri) => (AP.feedChangesSince('me', 0).find((r) => r.object_uri === uri) || {}).kind;
+
+  db.prepare("INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES ('vers','me','https://r.test/u/a','<p>x</p>')").run();
+  assert.equal(stand('vers'), 'new');
+
+  db.prepare("UPDATE ap_timeline SET content = '<p>anders</p>' WHERE id = 'vers'").run();
+  assert.equal(stand('vers'), 'updated');
+
+  db.prepare("DELETE FROM ap_timeline WHERE id = 'vers'").run();
+  assert.equal(stand('vers'), 'deleted');
+});
+
+test('het is een STAND en geen logboek: bewerkt-en-toen-weg leest als weg', () => {
+  // Een rij per bericht, niet een rij per gebeurtenis. Vijf keer bewerken blijft
+  // één rij, en dat is waarom er niets te snoeien valt. Mijn eerste test ging uit
+  // van een log en viel daar terecht over.
+  const alles = AP.feedChangesSince('me', 0);
+  const perUri = new Set(alles.map((r) => r.object_uri));
+  assert.equal(alles.length, perUri.size, 'geen enkel bericht komt twee keer voor');
+});
+
+test('nieuws van een ANDER account laat je merksteen staan', () => {
+  const voor = AP.feedCursor('me');
+  nieuwePost('buur');
+  assert.equal(AP.feedCursor('me'), voor, 'anders wordt iedereen wakker van andermans post');
+});
+
+test('is er al iets veranderd, dan wordt er niet gewacht', async () => {
+  const oud = AP.feedCursor('me');
+  nieuwePost();
+  const begin = Date.now();
+  const uit = await AP.waitForFeedChange('me', { since: oud, waitMs: 5000, tickMs: 20 });
+  assert.equal(uit.changed, true);
+  assert.equal(uit.waited, false, 'meteen antwoorden, niet eerst de tijd volmaken');
+  assert.ok(Date.now() - begin < 500);
+});
+
+test('zonder sinds is het gewoon de huidige stand', async () => {
+  const uit = await AP.waitForFeedChange('me', { waitMs: 5000, tickMs: 20 });
+  assert.equal(uit.changed, false);
+  assert.equal(uit.waited, false);
+  assert.equal(uit.cursor, AP.feedCursor('me'));
+});
+
+test('wachten wordt afgebroken zodra er iets binnenkomt', async () => {
+  const oud = AP.feedCursor('me');
+  const begin = Date.now();
+  setTimeout(() => nieuwePost(), 60);
+  const uit = await AP.waitForFeedChange('me', { since: oud, waitMs: 4000, tickMs: 20 });
+  assert.equal(uit.changed, true);
+  assert.equal(uit.waited, true);
+  assert.ok(Date.now() - begin < 2000, 'hij hoort wakker te worden, niet de tijd vol te maken');
+  assert.notEqual(uit.cursor, oud, 'en de nieuwe merksteen komt mee');
+});
+
+test('gebeurt er niets, dan komt hij leeg terug binnen de tijd', async () => {
+  const oud = AP.feedCursor('me');
+  const begin = Date.now();
+  const uit = await AP.waitForFeedChange('me', { since: oud, waitMs: 150, tickMs: 20 });
+  assert.equal(uit.changed, false);
+  assert.equal(uit.waited, true);
+  assert.equal(uit.cursor, oud);
+  assert.ok(Date.now() - begin >= 100, 'hij hoort wel echt gewacht te hebben');
+  assert.ok(Date.now() - begin < 3000, 'en niet langer dan gevraagd');
+});
+
+test('ophangen breekt het wachten af', async () => {
+  // Zonder dit blijft er een timer draaien voor een client die er niet meer is.
+  const oud = AP.feedCursor('me');
+  const ac = new AbortController();
+  const begin = Date.now();
+  setTimeout(() => ac.abort(), 60);
+  const uit = await AP.waitForFeedChange('me', { since: oud, waitMs: 4000, tickMs: 20, signal: ac.signal });
+  assert.equal(uit.changed, false);
+  assert.ok(Date.now() - begin < 2000);
+});
+
+test('meer dan vier wachters tegelijk: de rest krijgt meteen antwoord', async () => {
+  // Een client met een kapotte herverbind-lus mag de instance niet vastzetten.
+  // De overtolligen krijgen geen fout maar gewoon de huidige stand.
+  const oud = AP.feedCursor('me');
+  const lopend = [];
+  for (let i = 0; i < 4; i++) lopend.push(AP.waitForFeedChange('me', { since: oud, waitMs: 300, tickMs: 20 }));
+  await new Promise((r) => setTimeout(r, 30));
+  const vijfde = await AP.waitForFeedChange('me', { since: oud, waitMs: 4000, tickMs: 20 });
+  assert.equal(vijfde.busy, true);
+  assert.equal(vijfde.waited, false);
+  await Promise.all(lopend);
+  // En daarna is er weer plek: de teller moet netjes teruglopen.
+  const daarna = await AP.waitForFeedChange('me', { since: oud, waitMs: 60, tickMs: 20 });
+  assert.notEqual(daarna.busy, true);
+});
Index: test/forwarded-activities.test.js
===================================================================
--- test/forwarded-activities.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/forwarded-activities.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,228 @@
+// Doorgestuurde activiteiten (shaer-s8k): een geldige handtekening van iemand
+// anders dan de auteur is doorsturen, geen vervalsing. We geloven de bezorgde
+// inhoud niet en halen het object bij de bron op.
+//
+// Deze tests dekken dereferenceForwarded via de echte handleInbox, met een
+// gestubde fetch: het gaat om de BESLISSING (accepteren of weigeren) en om wat
+// er wordt opgeslagen, niet om HTTP.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+
+// IP-literals uit de documentatierange (TEST-NET-3): safeFetch doet dan GEEN
+// DNS-lookup (zie assertPublicHost) en ze staan niet in de geblokkeerde ranges,
+// dus de SSRF-preflight laat ze door en de gestubde fetch vangt het verzoek op.
+// Met verzonnen hostnamen faalde de preflight en kwam het nooit tot ophalen.
+const AUTEUR = 'https://203.0.113.10/users/anna';
+const DOORSTUURDER = 'https://203.0.113.20/users/relay';
+const NOTE_ID = 'https://203.0.113.10/notes/1';
+
+// Wat de bron teruggeeft als we het object ophalen. Per test aan te passen.
+let bron = null;
+let opgehaald = [];
+const echteFetch = globalThis.fetch;
+globalThis.fetch = async (url, opts = {}) => {
+  const u = String(url);
+  opgehaald.push({ url: u, ondertekend: !!(opts.headers && (opts.headers.Signature || opts.headers.signature)) });
+  if (u === NOTE_ID && bron) {
+    return new Response(JSON.stringify(bron), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+  }
+  return new Response('not found', { status: 404 });
+};
+
+// Een note die WIJ kennen: doorsturen gebeurt omdat wij in de thread zitten, en
+// sinds shaer-drf dereferencen we alleen als inReplyTo daarheen wijst.
+const ONZE_NOTE = 'https://203.0.113.10/notes/van-ons';
+db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, created_at)
+            VALUES (?,?,?,?,?,?)`).run(ONZE_NOTE, 'me', AUTEUR, 'Anna', '<p>x</p>', '2026-08-06 09:00:00');
+
+/** Een doorgestuurde Create: ondertekend door de doorstuurder, geschreven door de auteur. */
+function doorgestuurd(objectOverride) {
+  return {
+    type: 'Create',
+    actor: AUTEUR,
+    object: objectOverride !== undefined ? objectOverride : {
+      id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE,
+      content: '<p>BEZORGDE versie</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
+    },
+  };
+}
+const req = (body) => ({ body, headers: { signature: 'keyId="x",signature="y"' }, ip: '203.0.113.9' });
+const alsDoorstuurder = { id: DOORSTUURDER };
+
+test('doorgestuurde Create wordt geaccepteerd na ophalen bij de bron', async () => {
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>ECHTE versie</p>' };
+  opgehaald = [];
+  const act = doorgestuurd();
+  const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  assert.notEqual(status, 401, 'een doorgestuurde reactie hoort niet meer geweigerd te worden');
+  assert.ok(opgehaald.some((v) => v.url === NOTE_ID), 'het object hoort bij de bron opgehaald te zijn');
+  // Wat telt: de OPGEHAALDE inhoud wordt gebruikt, niet wat de doorstuurder gaf.
+  assert.match(act.object.content, /ECHTE versie/);
+  assert.doesNotMatch(act.object.content, /BEZORGDE/);
+});
+
+test('een doorstuurder die de inhoud verdraait wint daar niets mee', async () => {
+  // Hetzelfde echte id, maar de bezorgde payload liegt over de inhoud. De bron
+  // is de waarheid; de payload wordt weggegooid.
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>ECHTE versie</p>' };
+  const act = doorgestuurd({ id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>KOOP MIJN MUNTEN</p>' });
+  await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  assert.doesNotMatch(act.object.content, /MUNTEN/);
+});
+
+test('object op een ANDERE host dan de geclaimde actor wordt geweigerd', async () => {
+  // Zonder deze ankereis wijst een doorsturer je naar een host die hij zelf
+  // beheert, waar attributedTo alles kan beweren.
+  bron = { id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>x</p>' };
+  const act = doorgestuurd({ id: 'https://203.0.113.66/notes/1', type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>x</p>' });
+  const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+});
+
+test('de bron die het object NIET aan de geclaimde actor toeschrijft → geweigerd', async () => {
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: 'https://203.0.113.10/users/iemandanders', content: '<p>x</p>' };
+  const status = await AP.handleInbox(req(doorgestuurd()), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+});
+
+test('bron onbereikbaar → geweigerd, geen twijfelgeval opgeslagen', async () => {
+  bron = null;   // de stub geeft 404
+  const status = await AP.handleInbox(req(doorgestuurd()), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+});
+
+test('een doorgestuurde Delete blijft geweigerd', async () => {
+  // Niet te dereferencen: het object is per definitie weg.
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR };
+  const act = { type: 'Delete', actor: AUTEUR, object: NOTE_ID };
+  const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+});
+
+test('een ONGETEKENDE activiteit blijft geweigerd, ook met een geldig object', async () => {
+  // Zonder bewijs van wie het bezorgde is er niets om op te bouwen; dan mag er
+  // ook niet gedereferenced worden.
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
+  opgehaald = [];
+  const status = await AP.handleInbox({ body: doorgestuurd(), headers: {}, ip: '203.0.113.9' }, 'me', null);
+  assert.equal(status, 401);
+  assert.equal(opgehaald.some((v) => v.url === NOTE_ID), false, 'er hoort niet eens opgehaald te worden');
+});
+
+test('een object-id dat naar iets anders omleidt wordt geweigerd', async () => {
+  // De bron geeft een ander id terug dan we opvroegen: dan weten we niet wat we
+  // in handen hebben.
+  bron = { id: 'https://203.0.113.10/notes/999', type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
+  const status = await AP.handleInbox(req(doorgestuurd()), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+});
+
+test('een antwoord op iets dat we NIET kennen wordt niet opgehaald', async () => {
+  // De vernauwing uit shaer-drf. Zonder deze eis zijn claimedActor en object.id
+  // allebei door de aanvaller gekozen en eist het host-anker alleen dat ze aan
+  // elkaar gelijk zijn -- dan kan iedereen met een werkende actor ons naar elke
+  // URL sturen.
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
+  opgehaald = [];
+  const act = doorgestuurd({
+    id: NOTE_ID, type: 'Note', attributedTo: AUTEUR,
+    inReplyTo: 'https://203.0.113.10/notes/kennen-we-niet', content: '<p>x</p>',
+  });
+  const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  assert.equal(status, 401);
+  assert.equal(opgehaald.some((v) => v.url === NOTE_ID), false, 'er hoort niet eens opgehaald te worden');
+});
+
+test('zonder inReplyTo wordt er niets opgehaald', async () => {
+  bron = { id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' };
+  opgehaald = [];
+  const act = doorgestuurd({ id: NOTE_ID, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' });
+  assert.equal(await AP.handleInbox(req(act), 'me', alsDoorstuurder), 401);
+  assert.equal(opgehaald.some((v) => v.url === NOTE_ID), false);
+});
+
+test('de dereference haalt ONBETEKEND op als dat volstaat', async () => {
+  // Anders kan een ander ons een ondertekend verzoek naar een adres van zijn
+  // keuze laten sturen, met onze identiteit eronder.
+  //
+  // Eigen id: NOTE_ID is in een eerdere test mislukt en zit dus in de negatieve
+  // cache -- die zou deze poging overslaan. Dat de test daarop stukliep, is het
+  // bewijs dat de cache doet wat hij moet.
+  const VERS = 'https://203.0.113.10/notes/vers';
+  const stubOrig = globalThis.fetch;
+  globalThis.fetch = async (url, opts = {}) => {
+    if (String(url) === VERS) {
+      opgehaald.push({ url: VERS, ondertekend: !!(opts.headers && (opts.headers.Signature || opts.headers.signature)) });
+      return new Response(JSON.stringify({ id: VERS, type: 'Note', attributedTo: AUTEUR, content: '<p>x</p>' }),
+        { status: 200, headers: { 'content-type': 'application/activity+json' } });
+    }
+    return stubOrig(url, opts);
+  };
+  opgehaald = [];
+  const act = doorgestuurd({ id: VERS, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>y</p>' });
+  const status = await AP.handleInbox(req(act), 'me', alsDoorstuurder);
+  globalThis.fetch = stubOrig;
+  assert.notEqual(status, 401);
+  const pogingen = opgehaald.filter((v) => v.url === VERS);
+  assert.equal(pogingen.length, 1, 'één poging');
+  assert.equal(pogingen[0].ondertekend, false, 'en die was onbetekend');
+});
+
+test('een mislukte poging wordt onthouden, zodat een retry hem niet herhaalt', async () => {
+  // Mastodon herhaalt dagenlang; zonder cache doet elke herhaling de fetch
+  // opnieuw. De tweede bezorging hoort geen tweede fetch op te leveren.
+  const MIS = 'https://203.0.113.10/notes/mislukt';
+  bron = null;   // de stub geeft 404
+  const act = () => doorgestuurd({ id: MIS, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE, content: '<p>x</p>' });
+  opgehaald = [];
+  assert.equal(await AP.handleInbox(req(act()), 'me', alsDoorstuurder), 401);
+  const na1 = opgehaald.filter((v) => v.url === MIS).length;
+  assert.ok(na1 >= 1, 'de eerste poging haalt wel op');
+  opgehaald = [];
+  assert.equal(await AP.handleInbox(req(act()), 'me', alsDoorstuurder), 401);
+  assert.equal(opgehaald.filter((v) => v.url === MIS).length, 0, 'de tweede niet meer');
+});
+
+test('op de GEDEELDE inbox wordt de bron ook ondertekend opgehaald', async () => {
+  // Doorstuurverkeer landt op /ap/inbox, want we adverteren een sharedInbox --
+  // en daar is slugParam null. signedGetJson valt bij een lege slug terug op een
+  // ONBETEKENDE GET, dus een bron in secure mode was langs deze weg helemaal niet
+  // te dereferencen. Elke lokale actor is een geldige ondertekenaar, net als in
+  // verifyRequest sinds shaer-afq.
+  const GESLOTEN = 'https://203.0.113.10/notes/secure-mode';   // eigen id: buiten de negatieve cache
+  const note = {
+    id: GESLOTEN, type: 'Note', attributedTo: AUTEUR, inReplyTo: ONZE_NOTE,
+    content: '<p>uit secure mode</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
+  };
+  const stub = globalThis.fetch;
+  const pogingen = [];
+  globalThis.fetch = async (url, opts = {}) => {
+    if (String(url) !== GESLOTEN) return stub(url, opts);
+    const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
+    pogingen.push(ondertekend);
+    return ondertekend
+      ? new Response(JSON.stringify(note), { status: 200, headers: { 'content-type': 'application/activity+json' } })
+      : new Response('unauthorized', { status: 401 });
+  };
+  const status = await AP.handleInbox(req(doorgestuurd(note)), null, alsDoorstuurder);
+  globalThis.fetch = stub;
+  assert.equal(status, 202, 'de doorgestuurde Create hoort geaccepteerd te worden');
+  assert.deepEqual(pogingen, [false, true], 'eerst onbetekend, daarna pas ondertekend');
+});
+
+test.after(() => { globalThis.fetch = echteFetch; });
Index: test/guardian-follow-queues.test.js
===================================================================
--- test/guardian-follow-queues.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/guardian-follow-queues.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,86 @@
+// Guardianship Fase 2 (shaer-jdb): gate-verzoeken bereiken een C2S-client.
+//
+// De gating zelf werkt sinds shaer-hxg. Wat ontbrak was het DOORGEVEN: de
+// wachtrij `follows` gaf hardgecodeerd een lege lijst terug ("not built in
+// Klonkt yet (Fase 2)") en `outgoing-follows` serveerde alleen de ward-kant.
+// Daardoor bleven beide secties in Shaer altijd leeg.
+//
+// En daaronder zat de echte fout: shaer:direction werd bij het versturen gezet
+// en nergens gelezen, dus een UITGAAND verzoek werd opgeslagen als "deze ward
+// wil deze ward volgen" met het doel weggegooid.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const G = await import('../src/services/guardianship/index.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'oma', 'o@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'oma', 'Oma', 'u1');
+
+const WARD = 'https://kind.test/ap/users/kind';
+const VREEMDE = 'https://elders.test/users/vreemde';
+const DOEL = 'https://muziek.test/users/band';
+const ME = 'https://klonkt.test/ap/users/oma';
+
+test('een INKOMEND verzoek komt in de follows-wachtrij', () => {
+  G.follows.recordReview('oma', {
+    id: 'f-in', wardUri: WARD, follower: VREEMDE, followerHandle: '@vreemde@elders.test',
+    direction: 'incoming',
+  });
+  const c = G.followsCollection(`${ME}/queues/follows`, 'oma', ME);
+  assert.equal(c.totalItems, 1, 'deze wachtrij gaf hiervoor altijd een lege lijst terug');
+  const it = c.orderedItems[0];
+  assert.equal(it.actor, VREEMDE, 'de vreemde is de volger');
+  assert.equal(it.object, WARD, 'en de ward is het doel');
+  assert.equal(it['shaer:direction'], 'incoming');
+});
+
+test('een UITGAAND verzoek komt in de outgoing-wachtrij, met het doel erin', () => {
+  // De kern van de bug: hiervoor werd dit als inkomend opgeslagen met ward ==
+  // follower, en het doel -- waar het antwoord over gaat -- viel weg.
+  G.follows.recordReview('oma', {
+    id: 'f-uit', wardUri: WARD, follower: WARD, direction: 'outgoing',
+    target: DOEL, targetHandle: '@band@muziek.test',
+  });
+  const c = G.outgoingFollowsCollection(`${ME}/queues/outgoing-follows`, 'oma', ME);
+  const it = c.orderedItems.find((x) => x.id === 'f-uit');
+  assert.ok(it, 'een guardian zag hier nooit iets: de wachtrij serveerde alleen de ward-kant');
+  assert.equal(it.actor, WARD, 'de ward is hier de volger');
+  assert.equal(it.object, DOEL);
+  assert.equal(it['shaer:target'], DOEL, 'zonder dit valt er niets te beoordelen');
+  assert.equal(it['shaer:targetHandle'], '@band@muziek.test');
+  assert.equal(it['shaer:direction'], 'outgoing');
+});
+
+test('de twee richtingen lopen niet door elkaar', () => {
+  const inn = G.followsCollection(`${ME}/q/f`, 'oma', ME).orderedItems.map((x) => x.id);
+  const uit = G.outgoingFollowsCollection(`${ME}/q/o`, 'oma', ME).orderedItems.map((x) => x.id);
+  assert.deepEqual(inn, ['f-in']);
+  assert.ok(uit.includes('f-uit') && !uit.includes('f-in'));
+});
+
+test('bij een remote ward wordt het aantal guardians WEGGELATEN, niet op nul gezet', () => {
+  // De guardian-set van een remote ward wordt op diens eigen server bijgehouden.
+  // Nul sturen zou lezen als "dit kind heeft geen guardians", en dat is het
+  // tegenovergestelde van onbekend.
+  const it = G.followsCollection(`${ME}/q/f`, 'oma', ME).orderedItems[0];
+  assert.equal(it['shaer:guardianCount'], undefined);
+});
+
+test('een oude rij zonder richting telt als inkomend', () => {
+  // Bestaande rijen missen de kolom en zijn niet te repareren -- de informatie
+  // stond er nooit in. Ze horen terug te vallen op het geval dat ze toen waren.
+  db.prepare(`INSERT INTO ap_follow_reviews (id, guardian_slug, ward_uri, follower_uri, direction)
+              VALUES ('f-oud','oma',?,?,NULL)`).run(WARD, VREEMDE);
+  const inn = G.followsCollection(`${ME}/q/f`, 'oma', ME).orderedItems.map((x) => x.id);
+  assert.ok(inn.includes('f-oud'));
+});
Index: test/guardian-gate-panel.test.js
===================================================================
--- test/guardian-gate-panel.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/guardian-gate-panel.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,84 @@
+// Het gate-paneel per ward (shaer-ahy.1).
+//
+// Wat gated wordt is een ontwerpkeuze van de implementatie -- de FEP levert het
+// mechanisme en een paar voorbeelden, niet de lijst. GATE_CATALOGUE is die lijst
+// op een plek, en gateRows maakt er de rijen van die het paneel toont.
+//
+// De regels die hier bewaakt worden zijn geen opmaak maar betekenis: een gate
+// die je niet kunt verzetten hoort tOch zichtbaar te zijn, onbekend is niet uit,
+// en er wordt geen drempel verzonnen die we niet kennen.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+const gated = await import('../src/services/guardianship/gated.js');
+
+const rij = (rows, f) => rows.find((r) => r.feature === f);
+
+test('elke gate uit de catalogus krijgt een rij, ook de vaste', () => {
+  // Een paneel dat alleen verstelbare dingen toont verzwijgt de helft van wat er
+  // voor dit kind geldt.
+  const rows = gated.gateRows({});
+  assert.equal(rows.length, gated.GATE_CATALOGUE.length);
+  assert.ok(rij(rows, 'shaer:follows'), 'volgverzoeken zijn altijd gated en horen er tOch te staan');
+  assert.equal(rij(rows, 'shaer:follows').adjustable, false, 'maar niet te verzetten');
+});
+
+test('het soort staat erbij, want ze werken niet hetzelfde', () => {
+  const rows = gated.gateRows({});
+  assert.equal(rij(rows, 'shaer:externalEmbeds').kind, 'setting');
+  assert.equal(rij(rows, 'shaer:follows').kind, 'perRequest');
+});
+
+test('de drempel volgt het aantal guardians', () => {
+  const drie = gated.gateRows({ guardianCount: 3 });
+  assert.deepEqual(rij(drie, 'shaer:externalEmbeds').threshold, { need: 2, of: 3 });
+  const een = gated.gateRows({ guardianCount: 1 });
+  assert.deepEqual(rij(een, 'shaer:externalEmbeds').threshold, { need: 1, of: 1 });
+});
+
+test('een ONBEKEND aantal guardians levert GEEN drempel op', () => {
+  // Bij een ward elders wordt die set op diens eigen server bijgehouden. Een
+  // verzonnen getal leest als een feit, en dit is precies waar een guardian op
+  // afgaat voordat hij iets voorstelt.
+  const rows = gated.gateRows({ guardianCount: null });
+  assert.equal(rij(rows, 'shaer:externalEmbeds').threshold, null);
+});
+
+test('afspelen is dicht zolang zien UIT staat', () => {
+  const rows = gated.gateRows({ settings: { 'shaer:externalEmbeds': false } });
+  const p = rij(rows, 'shaer:externalPlayback');
+  assert.equal(p.adjustable, false);
+  assert.equal(p.blockedBy, 'shaer:externalEmbeds');
+});
+
+test('maar ONBEKEND is niet hetzelfde als uit', () => {
+  // Verbergen bij onbekend betekende ooit dat een guardian elders afspelen nooit
+  // kon voorstellen -- daar ging een hele voorstelronde de verkeerde gate in.
+  const rows = gated.gateRows({ settings: { 'shaer:externalEmbeds': null } });
+  const p = rij(rows, 'shaer:externalPlayback');
+  assert.equal(p.adjustable, true);
+  assert.equal(p.blockedBy, undefined);
+});
+
+test('een lopend voorstel hangt aan zijn eigen gate', () => {
+  const rows = gated.gateRows({
+    proposals: [{ feature: 'shaer:externalPlayback', value: true, status: 'open' }],
+  });
+  assert.equal(rij(rows, 'shaer:externalPlayback').proposal.status, 'open');
+  assert.equal(rij(rows, 'shaer:externalEmbeds').proposal, undefined);
+});
+
+test('wat er wacht staat bij de gate waar het op wacht', () => {
+  const rows = gated.gateRows({ waiting: { 'shaer:follows': 2 } });
+  assert.equal(rij(rows, 'shaer:follows').waiting, 2);
+});
+
+test('omkeerbaarheid staat per gate genoteerd', () => {
+  // Nu is alles omkeerbaar. Zodra independence erbij komt (shaer-90v) is dat het
+  // niet, en dan moet het paneel dat kunnen zeggen zonder verbouwing.
+  for (const r of gated.gateRows({})) assert.equal(typeof r.reversible, 'boolean');
+});
Index: test/guardian-gate-push.test.js
===================================================================
--- test/guardian-gate-push.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/guardian-gate-push.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,82 @@
+// Meldingen bij gated settings (shaer-fax, fase 1 -- de PWA eerst).
+//
+// De guardianship-module zendt VEERTIEN soorten gebeurtenissen uit; de
+// push-brug kende er vijf en deed `if (!texts) return`. Alles daarbuiten viel
+// stil weg -- inclusief de twee die er voor een guardian het meest toe doen:
+//
+//   gated_review   een mede-guardian heeft iets voorgesteld en JOUW antwoord is
+//                  nodig. Zonder melding loopt het venster leeg en verloopt het
+//                  voorstel. Een drempel die niemand ziet is geen drempel.
+//   gated_outcome  er is besloten.
+//
+// Getoetst wordt de BESLISSING (welke melding hoort hierbij), niet de bezorging.
+// Of web-push zelf werkt heeft zijn eigen leven.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'g', 'g@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'oma', 'Oma', 'u1');
+
+const KIND = 'https://kind.test/ap/users/kind';
+const push = (ev) => AP.guardianEventPush('oma', ev);
+
+test('een voorstel dat MIJN antwoord nodig heeft geeft een melding', () => {
+  const m = push({ kind: 'gated_review', feature: 'shaer:externalEmbeds', value: true, ward: KIND });
+  assert.ok(m, 'zonder dit weet een guardian niet dat er op hem gewacht wordt');
+  assert.equal(m.type, 'guardian');
+  assert.match(m.body, /kind/, 'de melding zegt over WIE het gaat');
+  assert.match(m.body, /linkvoorbeelden|link previews|Link-Vorschauen/i, 'en WELKE instelling');
+  assert.equal(m.url, '/guardian');
+});
+
+test('de stand staat erin, aan zowel als uit', () => {
+  const aan = push({ kind: 'gated_review', feature: 'shaer:externalEmbeds', value: true, ward: KIND });
+  const uit = push({ kind: 'gated_review', feature: 'shaer:externalEmbeds', value: false, ward: KIND });
+  assert.notEqual(aan.body, uit.body, 'anders lees je niet of iets AAN of UIT gezet wordt');
+});
+
+test('afspelen is niet hetzelfde als linkvoorbeelden', () => {
+  // Er is meer dan een gate, en ze betekenen heel verschillende dingen voor een
+  // kind. Een melding die dat niet zegt is nutteloos.
+  const a = push({ kind: 'gated_review', feature: 'shaer:externalEmbeds', value: true, ward: KIND });
+  const b = push({ kind: 'gated_review', feature: 'shaer:externalPlayback', value: true, ward: KIND });
+  assert.notEqual(a.body, b.body);
+});
+
+test('een besluit draagt de uitkomst', () => {
+  const af = push({ kind: 'gated_outcome', feature: 'shaer:externalPlayback', value: false, outcome: 'rejected', ward: KIND });
+  const aan = push({ kind: 'gated_outcome', feature: 'shaer:externalPlayback', value: false, outcome: 'accepted', ward: KIND });
+  assert.match(af.body, /afgewezen|rejected|abgelehnt/i);
+  assert.notEqual(af.body, aan.body);
+});
+
+test('een onbekende feature laat de melding NIET vervallen', () => {
+  // Liever een iets vager bericht dan geen bericht: een guardian die niets hoort
+  // denkt dat er niets speelt.
+  assert.ok(push({ kind: 'gated_review', feature: 'shaer:ietsNieuws', value: true, ward: KIND }));
+});
+
+test('een soort die we niet kennen blijft stil', () => {
+  // De vangnetregel blijft staan: liever geen melding dan een lege.
+  assert.equal(push({ kind: 'iets_heel_anders', ward: KIND }), null);
+});
+
+test('de bestaande soorten blijven werken', () => {
+  // Deze brug is verbouwd; wat er al doorheen ging moet er doorheen blijven gaan.
+  for (const kind of ['offer_received', 'offer_for_ward', 'committed', 'guardian_left', 'coguardian_left']) {
+    const m = push({ kind, ward: KIND, guardian: KIND, candidate: KIND });
+    assert.ok(m && m.title && m.body, `${kind} hoort een melding te geven`);
+  }
+});
Index: test/guardian-help-state.test.js
===================================================================
--- test/guardian-help-state.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/guardian-help-state.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,115 @@
+// Oppikken en afhandelen van een hulpvraag (shaer-lgo).
+//
+// Een hulpvraag gaat naar ALLE guardians van een kind, op verschillende servers.
+// Zonder gedeelde staat denken er twee dat de ander het oppakt -- en dat is
+// precies het scenario waar de reddingsboei voor bestaat.
+//
+// DE FAALSTAND IS HIER NIET VEILIG. Bij een gate is 'dicht' het veilige
+// antwoord; hier is de faalstand 'iedereen denkt dat het geregeld is', en dat is
+// gevaarlijker dan geen markering. Vandaar dat de meeste tests hieronder gaan
+// over wat er gebeurt als er IETS niet klopt: dan hoort de hulpvraag OPEN te
+// staan.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const help = await import('../src/services/guardianship/help.js');
+
+const NOTE = 'https://kind.test/ap/notes/hulp-1';
+const OMA = 'https://oma.test/ap/users/oma';
+const OPA = 'https://opa.test/ap/users/opa';
+
+test('een verse hulpvraag staat open', () => {
+  const s = help.statusOf(NOTE);
+  assert.equal(s.open, true);
+  assert.deepEqual(s.pickedUpBy, []);
+  assert.equal(s.handled, null);
+});
+
+test('oppikken mag STAPELEN', () => {
+  // Twee mensen die tegelijk reageren op een kind dat om hulp vraagt is geen
+  // probleem. Twee die allebei niets doen omdat de ander het geclaimd had, wel.
+  help.record(NOTE, OMA, 'pickup', '@oma@oma.test');
+  help.record(NOTE, OPA, 'pickup', '@opa@opa.test');
+  const s = help.statusOf(NOTE);
+  assert.equal(s.pickedUpBy.length, 2);
+  assert.equal(s.open, true, 'opgepikt is nog niet afgehandeld');
+});
+
+test('en het staat erbij DOOR WIE, want dat was de hele vraag', () => {
+  const s = help.statusOf(NOTE);
+  assert.ok(s.pickedUpBy.some((p) => p.handle === '@oma@oma.test'));
+  assert.ok(s.pickedUpBy.every((p) => p.at), 'met een tijdstip, anders kun je niet zien dat het oud wordt');
+});
+
+test('twee keer hetzelfde oppikken telt een keer', () => {
+  help.record(NOTE, OMA, 'pickup', '@oma@oma.test');
+  assert.equal(help.statusOf(NOTE).pickedUpBy.length, 2);
+});
+
+test('een oppik VERVALT NIET maar veroudert wel zichtbaar', () => {
+  // Barts keuze: niets verdwijnt, want een signaal dat wegvalt laat een
+  // hulpvraag er onaangeroerd uitzien terwijl er iemand mee bezig is. Het scherm
+  // toont in plaats daarvan hoe oud het is.
+  const rijen = [{ kind: 'pickup', guardian_uri: OMA, created_at: '2026-08-05T10:00:00.000Z' }];
+  const s = help.helpStatus(rijen, Date.parse('2026-08-07T10:00:00.000Z'));
+  assert.equal(s.open, true);
+  assert.equal(s.ageMs, 2 * 24 * 3600 * 1000, 'twee dagen oud, en dat hoort te zien te zijn');
+});
+
+test('afhandelen sluit hem, met naam', () => {
+  help.record(NOTE, OPA, 'handled', '@opa@opa.test');
+  const s = help.statusOf(NOTE);
+  assert.equal(s.open, false);
+  assert.equal(s.handled.handle, '@opa@opa.test');
+  assert.equal(s.ageMs, null, 'de leeftijd van een oppik zegt niets meer als het klaar is');
+});
+
+test('er is GEEN terugdraai', () => {
+  // Besluit van Bart: sluiten gebeurt met een stevige bevestiging, en leeft de
+  // vraag daarna nog, dan wordt hij OPNIEUW GESTELD -- een nieuwe hulpvraag. Er
+  // wordt niets herschreven.
+  assert.equal(typeof help.default.undo, 'undefined');
+  assert.equal(typeof help.default.reopen, 'undefined');
+});
+
+test('bij twijfel staat een hulpvraag OPEN', () => {
+  // Alles wat geen expliciete afsluiting is telt als open. Een lege lijst, rommel,
+  // een onbekende soort: allemaal 'er wacht nog iemand'. De omgekeerde fout --
+  // iets als afgehandeld tonen dat het niet is -- is de gevaarlijke.
+  for (const rijen of [[], null, [{ kind: 'iets', guardian_uri: OMA }], [{ kind: 'pickup', guardian_uri: OMA }]]) {
+    assert.equal(help.helpStatus(rijen).open, true);
+  }
+});
+
+test('een markering wordt herkend, en iets anders niet', () => {
+  assert.deepEqual(help.parseMarker({ 'shaer:helpPickup': NOTE }), { kind: 'pickup', noteUri: NOTE });
+  assert.deepEqual(help.parseMarker({ 'shaer:helpHandled': NOTE }), { kind: 'handled', noteUri: NOTE });
+  assert.equal(help.parseMarker({ content: '<p>hoi</p>' }), null);
+  assert.equal(help.parseMarker(null), null);
+  assert.equal(help.parseMarker({ 'shaer:helpPickup': '' }), null, 'leeg is geen markering');
+});
+
+test('afhandelen wint van oppikken in hetzelfde bericht', () => {
+  // Kan niet ontstaan uit onze eigen verzending, maar wel uit een andere
+  // implementatie. Het zwaarste signaal telt.
+  assert.equal(help.parseMarker({ 'shaer:helpPickup': NOTE, 'shaer:helpHandled': NOTE }).kind, 'handled');
+});
+
+test('een lijst hulpvragen kost EEN query', () => {
+  const N2 = 'https://kind.test/ap/notes/hulp-2';
+  help.record(N2, OMA, 'pickup', '@oma@oma.test');
+  const m = help.statusFor([NOTE, N2, 'https://kind.test/ap/notes/leeg']);
+  assert.equal(m.size, 3);
+  assert.equal(m.get(NOTE).open, false);
+  assert.equal(m.get(N2).pickedUpBy.length, 1);
+  assert.equal(m.get('https://kind.test/ap/notes/leeg').open, true, 'onbekend is open');
+});
Index: test/interaction-reactions.test.js
===================================================================
--- test/interaction-reactions.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/interaction-reactions.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,102 @@
+// shaer-ipb: ap_interactions.acted_* was de DERDE bron van "heb ik hierop
+// gereageerd", naast ap_timeline.liked/boosted en ap_my_reactions. shaer-9e9
+// trok de eerste twee samen en liet deze staan, omdat hij op het interactie-rij-
+// id gesleuteld was en de tussentabel op de object-URI.
+//
+// Nu is ook deze bron de tussentabel. Wat hier bewaakt wordt:
+//   - de migratie neemt bestaande acted_*-rijen mee
+//   - getInteractions leest uit de tussentabel, niet meer uit de kolom
+//   - dezelfde note die je als comment EN als post kent, is EEN feit
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+db.prepare('INSERT INTO posts (id, site_id, slug, author_id, title, content, status) VALUES (?,?,?,?,?,?,?)')
+  .run('p1', 's1', 'mijn-post', 'u1', 'Mijn post', '<p>x</p>', 'published');
+
+const site = { slug: 'me', title: 'Me' };
+const COMMENT = 'https://203.0.113.60/notes/comment-1';
+const COMMENT2 = 'https://203.0.113.60/notes/comment-2';
+
+function comment(uri, extra = {}) {
+  db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, acted_like, acted_boost)
+              VALUES ('reply', 'p1', ?, ?, 'Anna', '@anna@203.0.113.60', '<p>hoi</p>', ?, ?)`)
+    .run(uri, 'https://203.0.113.60/users/anna', extra.like ? 1 : 0, extra.boost ? 1 : 0);
+  return db.prepare('SELECT id FROM ap_interactions WHERE object_uri = ?').get(uri).id;
+}
+
+const knop = (uri) => {
+  const plat = [];
+  const loop = (ns) => ns.forEach((n) => { plat.push(n); loop(n.children || []); });
+  loop(AP.getInteractions('p1', 'https://klonkt.test', site).thread);
+  const n = plat.find((x) => x.noteId === uri);
+  assert.ok(n, `node ${uri} hoort in de thread te staan`);
+  return { like: n.acted_like, boost: n.acted_boost };
+};
+
+test('de migratie neemt acted_* mee naar de tussentabel', () => {
+  comment(COMMENT, { like: true });
+  const uit = AP.migrateReactions({ force: true });
+  assert.equal(uit.reacties, 1, 'één reactie hoort overgenomen te worden');
+  assert.deepEqual(AP.getReaction('me', COMMENT), { liked: true, boosted: false });
+});
+
+test('en is idempotent: nog een keer draaien voegt niets toe', () => {
+  const uit = AP.migrateReactions({ force: true });
+  assert.equal(uit.reacties, 0);
+});
+
+test('de knopstand komt uit de tussentabel, niet uit de kolom', () => {
+  // Zet de kolom en de tussentabel EXPLICIET tegenover elkaar. Zou de view nog
+  // uit de kolom lezen, dan zou hier true uit komen.
+  db.prepare('UPDATE ap_interactions SET acted_boost = 1 WHERE object_uri = ?').run(COMMENT);
+  assert.equal(knop(COMMENT).boost, false, 'de kolom liegt en wordt genegeerd');
+  AP.setReaction('me', COMMENT, 'boost', true);
+  assert.equal(knop(COMMENT).boost, true);
+});
+
+test('een reactie geven en intrekken laat niets achter', () => {
+  comment(COMMENT2);
+  assert.deepEqual(knop(COMMENT2), { like: false, boost: false });
+  AP.setReaction('me', COMMENT2, 'like', true);
+  assert.equal(knop(COMMENT2).like, true);
+  AP.setReaction('me', COMMENT2, 'like', false);
+  assert.equal(knop(COMMENT2).like, false);
+});
+
+test('dezelfde note als comment en als post is EEN feit', () => {
+  // De betekeniswijziging van deze bead, expliciet vastgelegd: like je een note
+  // in je Krant, dan staat de knop onder diezelfde note als comment ook aan.
+  // Twee knoppen die los van elkaar aan konden staan voor hetzelfde object was
+  // eerder een bug dan een feature -- er gaat immers één Like de fediverse in.
+  const BEIDE = 'https://203.0.113.60/notes/overlap';
+  comment(BEIDE);
+  db.prepare(`INSERT INTO ap_timeline (id, slug, author_uri, author_name, content, created_at)
+              VALUES (?,?,?,?,?,?)`).run(BEIDE, 'me', 'https://203.0.113.60/users/anna', 'Anna', '<p>x</p>', '2026-08-06 09:00:00');
+  AP.setReaction('me', BEIDE, 'like', true);
+  assert.equal(knop(BEIDE).like, true, 'de like uit de Krant hoort onder de comment te staan');
+});
+
+test('een reactie zonder object_uri blokkeert de migratie niet', () => {
+  // Een like of announce draagt geen object_uri, en fedi-react eist er een --
+  // zulke rijen kunnen dus nooit acted_* dragen. De migratie moet ze overslaan
+  // in plaats van erover te struikelen. De kolom is NOT NULL, dus zo'n rij draagt
+  // een lege string; zo staat hij ook op beta.
+  db.prepare(`INSERT INTO ap_interactions (kind, post_id, object_uri, actor_uri, actor_name, acted_like)
+              VALUES ('like', 'p1', '', ?, 'Bob', 1)`).run('https://203.0.113.60/users/bob');
+  const uit = AP.migrateReactions({ force: true });
+  assert.equal(uit.reacties, 0, 'niets over te nemen, en geen fout');
+});
Index: test/messages-conversations.test.js
===================================================================
--- test/messages-conversations.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/messages-conversations.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,162 @@
+// Berichten als gesprekken: antwoorden, mentions en je eigen verzonden
+// berichten vouwen samen tot draden, de rest van de stroom blijft ongemoeid.
+// Dit dekt de groepeerlogica zelf (zuiver, geen DB, geen netwerk); het
+// samenstellen van de stroom zit in getMessages en leunt op de database.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+const { threadKey, groupConversations } = AP;
+
+// Aflopend gesorteerd, zoals getMessages ze aanlevert.
+const ts = (n) => new Date(Date.UTC(2026, 0, 1, 12, n)).toISOString();
+
+test('threadKey: een post wint van een persoon', () => {
+  assert.equal(threadKey({ type: 'reply', post_slug: 'hallo', handle: '@anna@a.test' }), 'post:hallo');
+  assert.equal(threadKey({ type: 'sent', post_slug: 'hallo', to_handle: '@anna@a.test' }), 'post:hallo');
+  // Zonder post loopt de draad per tegenpartij, hoofdletter- en @-ongevoelig.
+  assert.equal(threadKey({ type: 'mention', handle: '@Anna@A.test' }), 'actor:anna@a.test');
+  assert.equal(threadKey({ type: 'sent', to_handle: 'Anna@a.test' }), 'actor:anna@a.test');
+});
+
+test('threadKey: alles wat geen gesprek is krijgt geen draad', () => {
+  for (const type of ['like', 'announce', 'follow', 'report', 'poll_done']) {
+    assert.equal(threadKey({ type, post_slug: 'hallo' }), null, `${type} hoort geen draad te krijgen`);
+  }
+  assert.equal(threadKey(null), null);
+  assert.equal(threadKey({ type: 'mention' }), null, 'een mention zonder afzender heeft geen sleutel');
+});
+
+test('ontvangen en verzonden op dezelfde post komen in EEN draad', () => {
+  const out = groupConversations([
+    { type: 'sent', post_slug: 'hallo', content: 'graag gedaan', created_at: ts(3) },
+    { type: 'reply', post_slug: 'hallo', post_title: 'Hallo fediverse', handle: '@anna@a.test', name: 'Anna', content: 'dank!', created_at: ts(2) },
+    { type: 'reply', post_slug: 'hallo', post_title: 'Hallo fediverse', handle: '@bo@b.test', name: 'Bo', content: 'mooi', created_at: ts(1) },
+  ]);
+  assert.equal(out.length, 1, 'drie berichten, één draad');
+  const t = out[0];
+  assert.equal(t.type, 'thread');
+  assert.equal(t.count, 3);
+  // De context hoort bij de draad: dit gesprek gaat over een post.
+  assert.deepEqual(t.post, { slug: 'hallo', title: 'Hallo fediverse' });
+  // Een gesprek leest naar beneden: oud → nieuw.
+  assert.deepEqual(t.messages.map((m) => m.content), ['mooi', 'dank!', 'graag gedaan']);
+  // De draad staat op de tijd van zijn NIEUWSTE bericht, zodat een levend
+  // gesprek bovenaan komt.
+  assert.equal(t.created_at, ts(3));
+  // Jij zit er niet bij als deelnemer, de anderen wel.
+  assert.deepEqual(t.people.map((p) => p.name), ['Bo', 'Anna']);
+  assert.equal(t.mine, true);
+});
+
+test('een mention zonder post loopt per persoon, niet per post', () => {
+  const out = groupConversations([
+    { type: 'sent', to_handle: '@cas@c.test', content: 'hoi terug', created_at: ts(2) },
+    { type: 'mention', handle: '@cas@c.test', name: 'Cas', content: 'hoi!', created_at: ts(1) },
+  ]);
+  assert.equal(out.length, 1);
+  assert.equal(out[0].key, 'actor:cas@c.test');
+  assert.equal(out[0].post, null, 'zonder post is er geen context-link');
+  assert.deepEqual(out[0].messages.map((m) => m.content), ['hoi!', 'hoi terug']);
+});
+
+test('twee posts blijven twee draden, op volgorde van hun nieuwste bericht', () => {
+  const out = groupConversations([
+    { type: 'reply', post_slug: 'twee', post_title: 'Twee', handle: '@anna@a.test', created_at: ts(4) },
+    { type: 'reply', post_slug: 'een', post_title: 'Een', handle: '@bo@b.test', created_at: ts(3) },
+    { type: 'reply', post_slug: 'twee', post_title: 'Twee', handle: '@bo@b.test', created_at: ts(2) },
+  ]);
+  assert.deepEqual(out.map((t) => t.key), ['post:twee', 'post:een']);
+  assert.equal(out[0].count, 2);
+  assert.equal(out[1].count, 1);
+});
+
+test('likes, boosts en follows stromen ongemoeid tussen de draden door', () => {
+  const out = groupConversations([
+    { type: 'like', post_slug: 'hallo', handle: '@anna@a.test', created_at: ts(4) },
+    { type: 'reply', post_slug: 'hallo', post_title: 'Hallo', handle: '@bo@b.test', created_at: ts(3) },
+    { type: 'follow', handle: '@cas@c.test', created_at: ts(2) },
+    { type: 'report', handle: '@dee@d.test', created_at: ts(1) },
+  ]);
+  assert.deepEqual(out.map((i) => i.type), ['like', 'thread', 'follow', 'report']);
+  // Een like op dezelfde post trekt die post NIET de draad in: het is geen
+  // gesprek, en Activiteit blijft zijn eigen chip houden.
+  assert.equal(out[0].post_slug, 'hallo');
+});
+
+test('de titel komt van welk bericht in de draad hem ook maar kent', () => {
+  // Een verzonden antwoord kent alleen de slug; een ontvangen antwoord de titel.
+  const out = groupConversations([
+    { type: 'sent', post_slug: 'hallo', created_at: ts(2) },
+    { type: 'reply', post_slug: 'hallo', post_title: 'Hallo fediverse', handle: '@anna@a.test', created_at: ts(1) },
+  ]);
+  assert.deepEqual(out[0].post, { slug: 'hallo', title: 'Hallo fediverse' });
+});
+
+test('lege en rommelige invoer levert geen kapotte draden op', () => {
+  assert.deepEqual(groupConversations([]), []);
+  assert.deepEqual(groupConversations(null), []);
+  // Een gesprekssoort zonder sleutel blijft een losse regel in plaats van te
+  // verdwijnen: nooit een bericht kwijtraken in de groepering.
+  const out = groupConversations([{ type: 'sent', content: 'aan niemand', created_at: ts(1) }]);
+  assert.equal(out.length, 1);
+  assert.equal(out[0].type, 'sent');
+});
+
+test('een direct bericht zonder to_handle valt terug op to_actors', () => {
+  // Precies het geval waarin het het onlogischst is dat er geen draad ontstaat:
+  // een gesprek dat JIJ begon. Zonder deze terugval bleef het een losse regel.
+  const key = threadKey({
+    type: 'sent',
+    to_actors: JSON.stringify(['https://a.test/users/anna', 'https://b.test/users/bo']),
+    created_at: ts(1),
+  });
+  assert.equal(key, 'actor:anna@a.test');
+  // Rommel in de kolom mag niets omgooien.
+  assert.equal(threadKey({ type: 'sent', to_actors: 'geen json' }), null);
+  assert.equal(threadKey({ type: 'sent', to_actors: '[]' }), null);
+});
+
+test('to_handle wint van to_actors, zodat een draad niet splitst', () => {
+  const a = threadKey({ type: 'sent', to_handle: '@anna@a.test' });
+  const b = threadKey({ type: 'sent', to_handle: '@anna@a.test', to_actors: JSON.stringify(['https://b.test/users/bo']) });
+  assert.equal(a, b, 'dezelfde tegenpartij hoort dezelfde sleutel te geven');
+});
+
+test('een eigen bericht met media rendert als een post, niet als kale tekst', async () => {
+  // note-body (gedeeld met de Krant) leest media_json met een `type`; ap_outbox
+  // bewaart attachments met een `mediaType`. Zonder vertaling viel een foto die
+  // JIJ meestuurde weg.
+  const db = (await import('../src/config/database.js')).default;
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run('mu', 'mu', 'mu@test', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('ms', 'media', 'Media', 'mu');
+  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, to_handle, content, attachments, created_at)
+    VALUES ('om1','media','pm1','een-post','@anna@a.test','<p>kijk</p>',?,'2026-08-06 09:00:00')`)
+    .run(JSON.stringify([{ url: '/media/foto.png', mediaType: 'image/png', name: 'foto' }]));
+
+  const t = AP.getMessages('media', 50, 0).find((i) => i.type === 'thread');
+  const sent = t.messages.find((m) => m.type === 'sent');
+  assert.deepEqual(JSON.parse(sent.media_json), [{ url: '/media/foto.png', type: 'image/png', name: 'foto' }]);
+});
+
+test('een eigen bericht zonder of met kapotte media houdt gewoon zijn tekst', async () => {
+  const db = (await import('../src/config/database.js')).default;
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('ms2', 'media2', 'M2', 'mu');
+  db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, to_handle, content, attachments, created_at)
+    VALUES ('om2','media2','pm2','p','@anna@a.test','<p>a</p>','geen json','2026-08-06 09:00:00'),
+           ('om3','media2','pm3','p','@bo@b.test','<p>b</p>',NULL,'2026-08-06 09:01:00')`).run();
+  const threads = AP.getMessages('media2', 50, 0).filter((i) => i.type === 'thread');
+  const alle = threads.flatMap((t) => t.messages);
+  assert.equal(alle.length, 2, 'geen bericht mag verdwijnen door kapotte media');
+  for (const m of alle) assert.equal(m.media_json, null);
+});
Index: test/messages.test.js
===================================================================
--- test/messages.test.js	(revision f85b2c37408ce5c5fc3bca3b94691552a2696f4b)
+++ test/messages.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -2,4 +2,8 @@
 // in één stroom, groepeert opeenvolgende likes/boosts op dezelfde post, en geeft
 // visibility door (voor de privé-badge). Besluit Robin+Bart 2026-07-16.
+//
+// Sinds Berichten gesprekken toont vouwen antwoorden, mentions en je eigen
+// verzonden berichten samen tot draden (zie messages-conversations.test.js voor
+// de groepeerlogica zelf); likes, boosts en follows blijven losse regels.
 //
 // Run: npm test   (= node --test)
@@ -42,10 +46,16 @@
 const msgs = AP.getMessages('me', 50);
 
-test('eigen outbound reply zit als "sent" in de stroom (nieuwste eerst)', () => {
-  const sent = msgs.find((m) => m.type === 'sent');
+test('eigen outbound reply en het ontvangen antwoord staan in EEN draad', () => {
+  const thread = msgs.find((m) => m.type === 'thread');
+  assert.ok(thread, 'draad ontbreekt');
+  assert.equal(msgs[0], thread, 'de draad met het nieuwste bericht hoort bovenaan');
+  const sent = thread.messages.find((m) => m.type === 'sent');
   assert.ok(sent, 'sent-item ontbreekt');
   assert.equal(sent.outboxId, 'out1');
   assert.equal(sent.to_handle, '@dana@r.test');
-  assert.equal(msgs[0].type, 'sent', 'nieuwste item hoort bovenaan');
+  // Hier gaat het om: Dana's antwoord en het jouwe zaten in aparte chips
+  // (Gesprekken en Verzonden) en staan nu op volgorde in dezelfde draad.
+  assert.deepEqual(thread.messages.map((m) => m.type), ['reply', 'sent']);
+  assert.equal(thread.post.slug, 'mijn-post');
 });
 
@@ -58,8 +68,11 @@
 
 test('privé-reply draagt visibility voor de badge en heeft post-context', () => {
-  const reply = msgs.find((m) => m.type === 'reply');
+  const thread = msgs.find((m) => m.type === 'thread');
+  const reply = thread.messages.find((m) => m.type === 'reply');
   assert.equal(reply.visibility, 'direct');
   assert.equal(reply.post_slug, 'mijn-post');
   assert.equal(reply.post_title, 'Mijn post');
+  // De context hangt ook aan de draad zelf: die voedt de link in de kop.
+  assert.deepEqual(thread.post, { slug: 'mijn-post', title: 'Mijn post' });
 });
 
Index: test/reactions-characterization.test.js
===================================================================
--- test/reactions-characterization.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/reactions-characterization.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,420 @@
+// KARAKTERISERING van "heb ik hierop gereageerd", vóór de opschoning van
+// shaer-9e9. Deze tests beschrijven wat de code NU doet, niet wat ze zou moeten
+// doen. Ze staan hier om de aanstaande refactor hoorbaar te maken.
+//
+// Waarom dat nodig is: dit gebied faalt stil. Elke mark*-aanroep zit in een
+// try/catch die niets doet, en een UPDATE die geen rij raakt is geen fout. Zo
+// kon de shaer:liked-bug (Klonkt 04aca12) maanden bestaan. Een refactor kan hier
+// dus slagen, groen testen en tóch state kwijtraken -- tenzij het huidige gedrag
+// eerst is vastgeschreven.
+//
+// LET OP: de tests onder "de twee bronnen lopen uiteen" roepen de PRIMITIEVEN
+// aan (markLiked, setMyReaction). Die blijven bewust gescheiden -- het
+// samenvoegen zit in setReaction, dus in de aanroepers, niet in de primitieven.
+// Deze tests blijven daarom groen na fase 1 en beschrijven dan nog steeds iets
+// waars: wie markLiked los aanroept, raakt de tussentabel niet. Dat is precies
+// waarom setReaction bestaat en waarom die primitieven op termijn intern moeten
+// worden.
+//
+// De invariant die fase 1 wél bewaakt staat onderaan, bij setReaction.
+//
+// Wat hier NIET in kan: de routes zelf (die vragen HTTP + sessie) en het
+// C2S-pad voor Like/Announce (dat doet netwerk; zie de kop van
+// c2s-outbox.test.js). Dit dekt de servicelaag waar alle drie de ingangen op
+// uitkomen.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+
+const uri = (n) => `https://r.test/ap/notes/${n}`;
+
+/** Een post van iemand anders in JOUW tijdlijn-cache. */
+function seedTimeline(id, author = 'https://r.test/users/anna') {
+  db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, created_at)
+              VALUES (?,?,?,?,?,?)`).run(id, 'me', author, 'Anna', '<p>x</p>', '2026-08-06 09:00:00');
+}
+
+// ── Ingang 1: de tijdlijn-route (posts.js ~1279-1304) ────────────────────
+// Schrijft de KOLOM en leest de kolom. De tussentabel blijft ongemoeid.
+
+test('tijdlijn-route: markLiked zet de kolom en getTimelineReaction leest hem', () => {
+  const u = uri('t1'); seedTimeline(u);
+  assert.deepEqual(AP.getTimelineReaction('me', u), { liked: false, boosted: false });
+  AP.markLiked('me', u);
+  assert.deepEqual(AP.getTimelineReaction('me', u), { liked: true, boosted: false });
+  AP.unmarkLiked('me', u);
+  assert.equal(AP.getTimelineReaction('me', u).liked, false);
+});
+
+test('tijdlijn-route: markBoosted zet de kolom, maar de Cirkel volgt de tussentabel', () => {
+  // BIJGEWERKT toen de weergave werd gelijkgetrokken. Vroeger trok een kale
+  // kolomvlag de post de Cirkel in; nu selecteert de Cirkel op de tussentabel,
+  // dus de primitief alleen is niet meer genoeg. Dat is het doel: tekenen en
+  // beslissen leunen op dezelfde bron.
+  const u = uri('t2'); seedTimeline(u);
+  AP.markBoosted('me', u);
+  assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'de kolom staat aan');
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false,
+    'maar dat brengt hem niet in de Cirkel');
+  // Via het echte schrijfpad wel.
+  AP.setReaction('me', u, 'boost', true);
+  assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'via setReaction wel');
+  AP.setReaction('me', u, 'boost', false);
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false);
+});
+
+test('tijdlijn-route: een vlag op een post die NIET in je tijdlijn staat gaat stil verloren', () => {
+  // Dit is het stille falen waar de refactor voor moet oppassen: geen fout,
+  // geen rij, geen spoor. Een UPDATE die niets raakt is voor SQLite in orde.
+  const u = uri('bestaat-niet');
+  AP.markLiked('me', u);
+  assert.deepEqual(AP.getTimelineReaction('me', u), { liked: false, boosted: false });
+});
+
+// ── Ingang 2: de interact-route (posts.js ~841-869) ──────────────────────
+// Schrijft de TUSSENTABEL en leest de tussentabel. De kolom blijft ongemoeid.
+
+test('interact-route: setMyReaction schrijft de tussentabel, ook zonder tijdlijnrij', () => {
+  const u = uri('i1');   // bewust NIET in ap_timeline
+  assert.deepEqual(AP.getMyReactions('me', u), { liked: false, boosted: false });
+  AP.setMyReaction('me', u, 'like', true);
+  assert.deepEqual(AP.getMyReactions('me', u), { liked: true, boosted: false });
+  AP.setMyReaction('me', u, 'boost', true);
+  assert.deepEqual(AP.getMyReactions('me', u), { liked: true, boosted: true });
+  AP.setMyReaction('me', u, 'like', false);
+  assert.deepEqual(AP.getMyReactions('me', u), { liked: false, boosted: true });
+});
+
+test('interact-route: tweemaal dezelfde reactie levert geen dubbele rij op', () => {
+  const u = uri('i2');
+  AP.setMyReaction('me', u, 'like', true);
+  AP.setMyReaction('me', u, 'like', true);
+  const n = db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions WHERE site_slug=? AND target_uri=?').get('me', u).n;
+  assert.equal(n, 1, 'de UNIQUE hoort het dubbel opslaan te voorkomen');
+});
+
+// ── De valkuil met naam: upsertBoostedNote is een INSERT ─────────────────
+
+test('upsertBoostedNote trekt een post die je NIET volgt je tijdlijn in', () => {
+  // Dit is geen vlag zetten. Zonder deze insert bestaat de rij niet, kan de
+  // vlag nergens landen, en verschijnt de boost nergens. Wie dit in de refactor
+  // vervangt door "gewoon markBoosted" boost iets wat daarna onvindbaar is.
+  const u = uri('b1');
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE id=?').get(u).n, 0);
+  AP.upsertBoostedNote('me', {
+    object_uri: u, actor_uri: 'https://r.test/users/bo', actor_name: 'Bo',
+    content: '<p>geboost</p>', media: '[]',
+  });
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM ap_timeline WHERE id=?').get(u).n, 1, 'de rij hoort aangemaakt te worden');
+  assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'en meteen als geboost gemarkeerd');
+  // BIJGEWERKT: de Cirkel volgt sinds het gelijktrekken van de weergave de
+  // tussentabel, en die vult alleen setReaction. upsertBoostedNote blijft
+  // verantwoordelijk voor de RIJ -- zonder die insert heeft de reactie nergens
+  // een post om bij te horen.
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false, 'de rij alleen is niet genoeg');
+  AP.setMyReaction('me', u, 'boost', true);
+  assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'met de tussentabel erbij wel');
+});
+
+// ── De twee bronnen lopen uiteen ─────────────────────────────────────────
+// De primitieven raken elkaar niet, en dat blijft na fase 1 zo: het
+// samenvoegen zit in setReaction. Deze drie leggen vast waarom die functie
+// moet bestaan, en waarom markLiked en broers uiteindelijk intern horen te
+// worden -- zolang ze los aanroepbaar zijn, kan een aanroeper de helft doen.
+
+test('HUIDIG GEDRAG: de tijdlijn-route vult de tussentabel niet', () => {
+  const u = uri('d1'); seedTimeline(u);
+  AP.markLiked('me', u);
+  assert.equal(AP.getTimelineReaction('me', u).liked, true, 'de kolom staat aan');
+  assert.equal(AP.getMyReactions('me', u).liked, false,
+    'de primitief raakt de tussentabel niet -- daarvoor is setReaction');
+});
+
+test('HUIDIG GEDRAG: de interact-route vult de kolom niet', () => {
+  const u = uri('d2'); seedTimeline(u);
+  AP.setMyReaction('me', u, 'like', true);
+  assert.equal(AP.getMyReactions('me', u).liked, true, 'de tussentabel staat aan');
+  assert.equal(AP.getTimelineReaction('me', u).liked, false,
+    'de primitief raakt de kolom niet -- daarvoor is setReaction');
+});
+
+test('HUIDIG GEDRAG: alleen het C2S-pad schrijft allebei', () => {
+  // Wat ingestOutboxActivity sinds Klonkt 04aca12 doet, hier nagebootst zonder
+  // het netwerk: setMyReaction + markLiked. Dit is het enige pad dat de twee
+  // bronnen gelijk houdt, en precies daarom werkt un-liken vanuit een app wel.
+  const u = uri('d3'); seedTimeline(u);
+  AP.setMyReaction('me', u, 'like', true);
+  AP.markLiked('me', u);
+  assert.equal(AP.getMyReactions('me', u).liked, true);
+  assert.equal(AP.getTimelineReaction('me', u).liked, true);
+});
+
+// ── De scheefheid, meetbaar (fase 0 uit shaer-9e9 als test) ─────────────
+
+test('meetbaar: hoeveel rijen hebben een vlag zonder tegenhanger', () => {
+  // Dezelfde query als fase 0 in shaer-9e9, hier als test zodat de refactor
+  // hem kan gebruiken: na fase 1 hoort dit getal 0 te zijn en te blijven.
+  const scheef = db.prepare(`
+    SELECT COUNT(*) AS n FROM ap_timeline t
+     WHERE (t.liked = 1 OR t.boosted = 1)
+       AND NOT EXISTS (SELECT 1 FROM ap_my_reactions r
+                        WHERE r.site_slug = t.slug AND r.target_uri = t.id)`).get().n;
+  assert.ok(scheef > 0, 'vandaag lopen ze uiteen; na fase 1 hoort deze assert omgedraaid te worden naar === 0');
+});
+
+// ── De invariant van fase 1: setReaction houdt de bronnen gelijk ─────────
+
+test('setReaction: een like landt in BEIDE bronnen', () => {
+  const u = uri('sr1'); seedTimeline(u);
+  AP.setReaction('me', u, 'like', true);
+  assert.equal(AP.getMyReactions('me', u).liked, true, 'tussentabel');
+  assert.equal(AP.getTimelineReaction('me', u).liked, true, 'afgeleide vlag');
+  AP.setReaction('me', u, 'like', false);
+  assert.equal(AP.getMyReactions('me', u).liked, false);
+  assert.equal(AP.getTimelineReaction('me', u).liked, false);
+});
+
+test('setReaction: een boost met note trekt de post je tijdlijn in EN vult beide', () => {
+  // De valkuil uit het plan: zonder de note bestaat de rij niet en landt de
+  // vlag nergens, dus zou de boost onvindbaar zijn.
+  const u = uri('sr2');
+  AP.setReaction('me', u, 'boost', true, {
+    note: { object_uri: u, actor_uri: 'https://r.test/users/bo', actor_name: 'Bo', content: '<p>x</p>', media: '[]' },
+  });
+  assert.equal(AP.getMyReactions('me', u).boosted, true, 'tussentabel');
+  assert.equal(AP.getTimelineReaction('me', u).boosted, true, 'afgeleide vlag');
+  assert.ok(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), 'en zichtbaar in de Cirkel');
+  AP.setReaction('me', u, 'boost', false);
+  assert.equal(AP.getMyReactions('me', u).boosted, false);
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false);
+});
+
+test('setReaction: flagUri bepaalt de sleutel voor BEIDE bronnen', () => {
+  // Vroeger kreeg de tussentabel de URI die de client stuurde en de vlag de
+  // opgeloste. Nu is het er één, anders is dezelfde like onvindbaar vanaf een
+  // pagina die de andere URI kent.
+  const gestuurd = 'https://r.test/@anna/123';
+  const opgelost = uri('sr3');
+  seedTimeline(opgelost);
+  AP.setReaction('me', gestuurd, 'like', true, { flagUri: opgelost });
+  assert.equal(AP.getReaction('me', opgelost).liked, true, 'onder de opgeloste uri');
+  assert.equal(AP.getTimelineReaction('me', opgelost).liked, true, 'en de vlag ook');
+  assert.equal(AP.getMyReactions('me', gestuurd).liked, false, 'niet meer onder de gestuurde');
+});
+
+test('setReaction: rommelige invoer doet niets in plaats van iets halfs', () => {
+  AP.setReaction('me', uri('sr4'), 'sterretje', true);
+  assert.equal(AP.getMyReactions('me', uri('sr4')).liked, false);
+  AP.setReaction('', uri('sr5'), 'like', true);
+  AP.setReaction('me', '', 'like', true);
+  assert.equal(AP.getMyReactions('me', uri('sr5')).liked, false);
+});
+
+// ── Fase 2: lezen komt uit de tussentabel ────────────────────────────────
+
+test('getReaction leest de tussentabel, niet de kolom', () => {
+  const u = uri('g1'); seedTimeline(u);
+  AP.setReaction('me', u, 'like', true);
+  assert.deepEqual(AP.getReaction('me', u), { liked: true, boosted: false });
+  AP.setReaction('me', u, 'boost', true);
+  assert.deepEqual(AP.getReaction('me', u), { liked: true, boosted: true });
+});
+
+test('WAAROM DE BACKFILL EERST MOET: een kale kolomvlag is onzichtbaar voor getReaction', () => {
+  // Dit is de reden dat scripts/backfill-reactions.mjs tussen fase 1 en 2 hoort.
+  // Een reactie van vóór fase 1 staat alleen in de kolom; zodra de lezers de
+  // tussentabel volgen is die stil verdwenen -- geen fout, geen spoor.
+  const u = uri('g2'); seedTimeline(u);
+  AP.markLiked('me', u);                                  // zoals de oude tijdlijn-route
+  assert.equal(AP.getTimelineReaction('me', u).liked, true, 'de kolom staat aan');
+  assert.equal(AP.getReaction('me', u).liked, false, 'maar het nieuwe leespad ziet hem niet');
+  // Wat de backfill doet:
+  AP.setMyReaction('me', u, 'like', true);
+  assert.equal(AP.getReaction('me', u).liked, true, 'na aanvullen wel');
+});
+
+test('getReactionsFor haalt een hele pagina in één keer op', () => {
+  const a = uri('g3'), b = uri('g4'), c = uri('g5');
+  seedTimeline(a); seedTimeline(b);
+  AP.setReaction('me', a, 'like', true);
+  AP.setReaction('me', b, 'boost', true);
+  const m = AP.getReactionsFor('me', [a, b, c]);
+  assert.equal(m.get(a).liked, true);
+  assert.equal(m.get(b).boosted, true);
+  assert.equal(m.get(c), undefined, 'wie niets heeft komt niet in de map; de aanroeper valt terug op false');
+  // Dezelfde uitkomst als per stuk vragen, zodat de batch geen eigen waarheid wordt.
+  for (const u of [a, b]) assert.deepEqual(m.get(u), AP.getReaction('me', u));
+});
+
+test('getReactionsFor: lege of rommelige invoer geeft een lege map', () => {
+  assert.equal(AP.getReactionsFor('me', []).size, 0);
+  assert.equal(AP.getReactionsFor('me', null).size, 0);
+  assert.equal(AP.getReactionsFor('', [uri('g3')]).size, 0);
+});
+
+// ── Fase 3: het publieke oppervlak is versmald ───────────────────────────
+
+test('de primitieven zijn niet meer bereikbaar via het service-object', async () => {
+  // Routes doen `import ActivityPubService from ...` en werken dus met het
+  // default-object. Zolang markLiked daar in staat, kan een aanroeper de helft
+  // schrijven -- en dat is niet hypothetisch: precies zo bleef shaer:liked
+  // maandenlang false. De named exports blijven bestaan voor intern gebruik en
+  // voor deze tests.
+  const svc = (await import('../src/services/ActivityPubService.js')).default;
+  for (const naam of ['markLiked', 'unmarkLiked', 'markBoosted', 'unmarkBoosted',
+                      'setMyReaction', 'getMyReactions', 'getTimelineReaction']) {
+    assert.equal(svc[naam], undefined, `${naam} hoort niet op het publieke oppervlak te staan`);
+  }
+  // Wat er WEL hoort te staan: het ene schrijfpad en het ene leespad.
+  for (const naam of ['setReaction', 'getReaction', 'getReactionsFor']) {
+    assert.equal(typeof svc[naam], 'function', `${naam} hoort er wel te zijn`);
+  }
+});
+
+// ── De permalink en de object-URI zijn dezelfde post ─────────────────────
+
+test('een like uit de Krant is zichtbaar op de interact-pagina (Robins melding)', () => {
+  // De Krant kent een post als AP-object-URI, de interact-pagina als permalink.
+  // Zochten die twee in verschillende sleutelruimtes, dan toonde een geboost en
+  // geliket bericht daar geen enkele highlight -- terwijl de reactie bestond.
+  const obj = uri('perm1');
+  const permalink = 'https://sound-fabrics.com/effortlesseffect';
+  db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
+              VALUES (?,?,?,?,?,?,?)`)
+    .run(obj, 'me', 'https://r.test/users/anna', 'Anna', '<p>x</p>', permalink, '2026-08-06 09:00:00');
+
+  AP.setReaction('me', obj, 'like', true);      // zoals de Krant het doet
+  AP.setReaction('me', obj, 'boost', true);
+  // en de interact-pagina vraagt het met de permalink:
+  assert.deepEqual(AP.getReaction('me', permalink), { liked: true, boosted: true });
+});
+
+test('andersom net zo: reageren via de permalink landt op de object-URI', () => {
+  const obj = uri('perm2');
+  const permalink = 'https://sound-fabrics.com/tweede';
+  db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
+              VALUES (?,?,?,?,?,?,?)`)
+    .run(obj, 'me', 'https://r.test/users/bo', 'Bo', '<p>y</p>', permalink, '2026-08-06 09:00:00');
+
+  AP.setReaction('me', permalink, 'like', true);   // zoals de interact-pagina het doet
+  assert.equal(AP.getReaction('me', obj).liked, true, 'onder de object-uri opgeslagen');
+  assert.equal(AP.getTimelineReaction('me', obj).liked, true, 'dus de vlag landt ook goed');
+  assert.equal(AP.getReaction('me', permalink).liked, true, 'en blijft vindbaar via de permalink');
+});
+
+test('een reactie op iets buiten je tijdlijn blijft gewoon werken', () => {
+  // Kennen we de post niet, dan is er niets te canoniseren en blijft de invoer
+  // de sleutel. Een like op een vreemde post mag daar niet op stuklopen.
+  const onbekend = 'https://elders.test/notes/xyz';
+  AP.setReaction('me', onbekend, 'like', true);
+  assert.equal(AP.getReaction('me', onbekend).liked, true);
+});
+
+// ── De migratie bij boot ─────────────────────────────────────────────────
+
+test('migrateReactions hersleutelt een permalink-rij naar de object-URI', () => {
+  // De oude interact-route bewaarde de URI waarmee je binnenkwam, en de
+  // bookmarklet geeft de permalink door. Zonder hersleutelen zijn die rijen
+  // wees zodra er op de object-URI gezocht wordt.
+  const obj = uri('mig1');
+  const permalink = 'https://sound-fabrics.com/oud-artikel';
+  db.prepare(`INSERT OR IGNORE INTO ap_timeline (id, slug, author_uri, author_name, content, url, created_at)
+              VALUES (?,?,?,?,?,?,?)`)
+    .run(obj, 'me', 'https://r.test/users/anna', 'Anna', '<p>x</p>', permalink, '2026-08-06 09:00:00');
+  db.prepare("INSERT OR IGNORE INTO ap_my_reactions (site_slug, target_uri, kind, created_at) VALUES (?,?,?,?)")
+    .run('me', permalink, 'like', '2026-07-01 12:00:00');
+
+  const uitkomst = AP.migrateReactions({ force: true });
+  assert.ok(uitkomst.hersleuteld >= 1);
+  assert.equal(AP.getReaction('me', obj).liked, true, 'nu vindbaar op de object-uri');
+  const rij = db.prepare('SELECT created_at FROM ap_my_reactions WHERE site_slug=? AND target_uri=? AND kind=?').get('me', obj, 'like');
+  assert.equal(rij.created_at, '2026-07-01 12:00:00', 'bij hersleutelen blijft de oorspronkelijke datum staan');
+  const oud = db.prepare('SELECT COUNT(*) AS n FROM ap_my_reactions WHERE target_uri=?').get(permalink).n;
+  assert.equal(oud, 0, 'de permalink-rij is opgeruimd');
+});
+
+test('migrateReactions vult een kale kolomvlag aan', () => {
+  const u = uri('mig2'); seedTimeline(u);
+  AP.markLiked('me', u);                                  // zoals de oude Krant-route
+  assert.equal(AP.getReaction('me', u).liked, false, 'vooraf onzichtbaar');
+  AP.migrateReactions({ force: true });
+  assert.equal(AP.getReaction('me', u).liked, true, 'daarna zichtbaar');
+});
+
+test('migrateReactions is idempotent en respecteert de versievlag', () => {
+  const eerste = AP.migrateReactions({ force: true });
+  const tweede = AP.migrateReactions({ force: true });
+  assert.equal(tweede.hersleuteld, 0, 'niets meer te hersleutelen');
+  assert.equal(tweede.aangevuld, 0, 'niets meer aan te vullen');
+  assert.ok(eerste.hersleuteld >= 0);
+  // De vlag is VERSIEgebonden, en niet aan één vast getal: een oudere vlag laat
+  // de migratie opnieuw lopen -- zo doet een bump als v2 zijn werk -- en een
+  // gelijke of hogere slaat hem over. Een hardgecodeerd versienummer hier zou bij
+  // elke bump omvallen zonder dat er iets stuk is.
+  const vlag = (v) => db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?,?)').run('reactions_migration_version', v);
+  vlag('1');
+  assert.equal(AP.migrateReactions().overgeslagen, false, 'een oudere vlag laat hem opnieuw lopen');
+  vlag('999');
+  assert.equal(AP.migrateReactions().overgeslagen, true, 'een gelijke of hogere vlag slaat over');
+});
+
+test('migrateReactions --dry-run schrijft niets', () => {
+  const u = uri('mig3'); seedTimeline(u);
+  AP.markLiked('me', u);
+  const telling = AP.migrateReactions({ force: true, dryRun: true });
+  assert.ok(telling.aangevuld >= 1, 'hij ziet wel wat er te doen is');
+  assert.equal(AP.getReaction('me', u).liked, false, 'maar heeft niets geschreven');
+});
+
+// ── De weergave leest dezelfde bron als de knop ──────────────────────────
+
+test('getTimeline tekent liked/boosted uit de tussentabel, niet uit de kolom', () => {
+  // De Krant tekende zijn knoppen op ap_timeline.liked/boosted terwijl de
+  // toggle al uit getReaction besliste. Tekenen en beslissen leunden dus op
+  // verschillende bronnen -- ze waren het alleen eens zolang iets ze gelijk
+  // hield. Hier wordt de kolom expres tegengesteld gezet.
+  const u = uri('tl1'); seedTimeline(u);
+  AP.setReaction('me', u, 'like', true);
+  db.prepare('UPDATE ap_timeline SET liked = 0, boosted = 1 WHERE slug = ? AND id = ?').run('me', u);
+
+  const rij = AP.getTimeline('me', 50, 0).find((r) => r.id === u);
+  assert.equal(rij.liked, true, 'de tussentabel zegt geliket, dus de knop hoort aan');
+  assert.equal(rij.boosted, false, 'en de kolom die iets anders beweert telt niet');
+});
+
+test('de Cirkel selecteert op de tussentabel', () => {
+  const u = uri('tl2'); seedTimeline(u);
+  // Kolom aan, tussentabel leeg: hoort NIET in de Cirkel te staan.
+  db.prepare('UPDATE ap_timeline SET boosted = 1 WHERE slug = ? AND id = ?').run('me', u);
+  assert.equal(AP.getCirkelPosts('me', 60, 0).some((p) => p.id === u), false,
+    'een kale kolomvlag trekt niets meer de Cirkel in');
+  // En andersom: tussentabel aan, kolom uit.
+  db.prepare('UPDATE ap_timeline SET boosted = 0 WHERE slug = ? AND id = ?').run('me', u);
+  AP.setMyReaction('me', u, 'boost', true);
+  const post = AP.getCirkelPosts('me', 60, 0).find((p) => p.id === u);
+  assert.ok(post, 'de tussentabel bepaalt wat er in de Cirkel staat');
+  assert.ok(post.boosted, 'en het veld dat de view tekent komt daar ook vandaan');
+});
+
+test('boostedCount telt de tussentabel, en alleen wat in je tijdlijn staat', () => {
+  const inTl = uri('tl3'), buiten = 'https://elders.test/notes/qqq';
+  seedTimeline(inTl);
+  const voor = AP.boostedCount('me');
+  AP.setMyReaction('me', inTl, 'boost', true);
+  AP.setMyReaction('me', buiten, 'boost', true);
+  assert.equal(AP.boostedCount('me'), voor + 1, 'alleen de post die in je tijdlijn staat telt mee');
+});
Index: test/seen-notes.test.js
===================================================================
--- test/seen-notes.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/seen-notes.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,122 @@
+// shaer-e9g: onthoud de URI van antwoorden die we al ontvangen.
+//
+// Antwoorden van accounts die je volgt komen gewoon binnen, ondertekend door de
+// schrijver zelf. Ze horen niet in de Krant (belongsInTimeline weigert alles met
+// een inReplyTo) en werden daarna nergens bewaard. Kwam er later een
+// DOORGESTUURD antwoord op zo'n bericht, dan kende Klonkt de ouder niet en wees
+// het af -- terwijl het die ouder wel degelijk had gehad. Op boiert ging daar 72%
+// van het doorstuurverkeer op stuk.
+//
+// Wat hier bewaakt wordt is vooral de grens: onthouden gebeurt ALLEEN voor
+// schrijvers die je zelf volgt. Zou dat voor iedereen gelden, dan kan een vreemde
+// eerst een bericht neerleggen en daarna met een antwoord daarop de dereference
+// naar een adres van zijn keuze sturen.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+
+// IP-literals: safeFetch slaat de DNS-lookup over, dus offline.
+const GEVOLGD = 'https://203.0.113.10/users/catsalad';     // volgen we
+const VREEMDE = 'https://203.0.113.90/users/onbekend';     // volgen we niet
+const DOORSTUURDER = 'https://203.0.113.20/users/relay';
+
+db.prepare(`INSERT INTO ap_following (slug, actor_uri, handle, status) VALUES (?,?,?,?)`)
+  .run('me', GEVOLGD, '@catsalad@203.0.113.10', 'accepted');
+
+let bron = null;
+const echteFetch = globalThis.fetch;
+globalThis.fetch = async (url) => {
+  const u = String(url);
+  if (bron && bron.id === u) {
+    return new Response(JSON.stringify(bron), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+  }
+  return new Response('not found', { status: 404 });
+};
+
+const req = (body) => ({ body, headers: { signature: 'keyId="x",signature="y"' }, ip: '203.0.113.9' });
+const gezien = (uri) => !!db.prepare('SELECT 1 FROM ap_seen_notes WHERE uri = ?').get(uri);
+
+/** Een gewoon bezorgd antwoord: ondertekend door de schrijver zelf. */
+function bezorgdAntwoord(auteur, id, inReplyTo) {
+  return {
+    type: 'Create',
+    actor: auteur,
+    object: {
+      id, type: 'Note', attributedTo: auteur, inReplyTo,
+      content: '<p>een antwoord midden in een draad</p>',
+      to: ['https://www.w3.org/ns/activitystreams#Public'],
+    },
+  };
+}
+
+test('een antwoord van iemand die we volgen wordt onthouden', async () => {
+  const uri = 'https://203.0.113.10/notes/catsalad-1';
+  const status = await AP.handleInbox(req(bezorgdAntwoord(GEVOLGD, uri, 'https://203.0.113.10/notes/draad-start')), 'me', { id: GEVOLGD });
+  assert.equal(status, 202);
+  assert.ok(gezien(uri), 'de URI hoort bewaard te zijn');
+});
+
+test('maar het verschijnt NIET in de Krant', () => {
+  // Alleen de URI. Er verandert niets aan wat je te zien krijgt -- een antwoord
+  // hoort in zijn draad, niet in de tijdlijn, en dat blijft zo.
+  assert.equal(db.prepare('SELECT COUNT(*) AS n FROM ap_timeline').get().n, 0);
+});
+
+test('een antwoord van een VREEMDE wordt niet onthouden', async () => {
+  const uri = 'https://203.0.113.90/notes/vreemde-1';
+  await AP.handleInbox(req(bezorgdAntwoord(VREEMDE, uri, 'https://203.0.113.90/notes/iets')), 'me', { id: VREEMDE });
+  assert.equal(gezien(uri), false, 'anders kan een vreemde de poort voor zichzelf openzetten');
+});
+
+test('een gewone POST van iemand die we volgen wordt niet als gezien-antwoord bewaard', async () => {
+  // Die gaat naar de Krant en staat daarmee al in ap_timeline; een tweede
+  // administratie zou alleen maar uiteen kunnen lopen.
+  const uri = 'https://203.0.113.10/notes/gewone-post';
+  const act = bezorgdAntwoord(GEVOLGD, uri, null);
+  delete act.object.inReplyTo;
+  await AP.handleInbox(req(act), 'me', { id: GEVOLGD });
+  assert.equal(gezien(uri), false);
+  assert.ok(db.prepare('SELECT 1 FROM ap_timeline WHERE id = ?').get(uri), 'die hoort juist wel in de Krant');
+});
+
+test('en dan komt het doorgestuurde antwoord OP dat bericht wel binnen', async () => {
+  // De hele reden van deze bead. Zonder het onthouden was dit een afwijzing met
+  // "skipped (unknown inReplyTo)".
+  const ouder = 'https://203.0.113.10/notes/catsalad-1';   // hierboven onthouden
+  const antwoord = 'https://203.0.113.30/notes/drinkcoaster-1';
+  const schrijver = 'https://203.0.113.30/users/drinkcoaster';
+  bron = {
+    id: antwoord, type: 'Note', attributedTo: schrijver, inReplyTo: ouder,
+    content: '<p>ECHTE versie</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
+  };
+  const act = { type: 'Create', actor: schrijver, object: { ...bron, content: '<p>BEZORGDE versie</p>' } };
+  const status = await AP.handleInbox(req(act), 'me', { id: DOORSTUURDER });
+  assert.equal(status, 202, 'de doorgestuurde Create hoort geaccepteerd te worden');
+});
+
+test('een doorgestuurd antwoord op een bericht dat we NIET kenden blijft geweigerd', async () => {
+  const antwoord = 'https://203.0.113.30/notes/drinkcoaster-2';
+  const schrijver = 'https://203.0.113.30/users/drinkcoaster';
+  bron = {
+    id: antwoord, type: 'Note', attributedTo: schrijver, inReplyTo: 'https://203.0.113.90/notes/nooit-gezien',
+    content: '<p>x</p>', to: ['https://www.w3.org/ns/activitystreams#Public'],
+  };
+  const act = { type: 'Create', actor: schrijver, object: bron };
+  assert.equal(await AP.handleInbox(req(act), 'me', { id: DOORSTUURDER }), 401);
+});
+
+test.after(() => { globalThis.fetch = echteFetch; });
Index: test/signature-verification.test.js
===================================================================
--- test/signature-verification.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
+++ test/signature-verification.test.js	(revision 952baf39d4219c186a0428025fb689aec377f7f5)
@@ -0,0 +1,131 @@
+// verifyRequest met een ECHTE handtekening.
+//
+// Dit ontbrak, en dat is hoe het hoort te heten: de hele testsuite maakte
+// nergens een handtekening aan. De inbox-tests injecteren preVerified en slaan
+// verifyRequest dus over; de authorized-fetch-tests raken alleen fetchActor.
+// Ondertussen is het sleutel-ophaalpad op 2026-08-06 twee keer verbouwd
+// (9561d58 ondertekenen bij secure mode, efe5633 onbetekend eerst) zonder één
+// test die aantoont dat verificatie nog SLAAGT.
+//
+// Hier wordt een echt RSA-paar gemaakt, het actor-document met die publieke
+// sleutel geserveerd via de gestubde fetch, en een verzoek ondertekend zoals
+// een fediverse-server het doet.
+//
+// Run: npm test
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import crypto from 'crypto';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+  .run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Me', 'u1');
+
+const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
+  modulusLength: 2048,
+  publicKeyEncoding: { type: 'spki', format: 'pem' },
+  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+});
+
+const ACTOR = 'https://203.0.113.70/users/zender';
+const KEY_ID = `${ACTOR}#main-key`;
+// Secure mode: dit actor-document komt alleen op een ondertekend verzoek.
+const SECURE_ACTOR = 'https://203.0.113.80/users/gesloten';
+const SECURE_KEY_ID = `${SECURE_ACTOR}#main-key`;
+
+const actorDoc = (id) => ({
+  id, type: 'Person', preferredUsername: 'zender', inbox: `${id}/inbox`,
+  publicKey: { id: `${id}#main-key`, owner: id, publicKeyPem: publicKey },
+});
+
+const echteFetch = globalThis.fetch;
+globalThis.fetch = async (url, opts = {}) => {
+  const u = String(url);
+  const ondertekend = !!(opts.headers && (opts.headers.Signature || opts.headers.signature));
+  const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+  if (u === ACTOR) return json(actorDoc(ACTOR));
+  if (u === SECURE_ACTOR) return ondertekend ? json(actorDoc(SECURE_ACTOR)) : new Response('unauthorized', { status: 401 });
+  return new Response('not found', { status: 404 });
+};
+
+/** Bouwt een verzoek zoals een fediverse-server het aflevert: ondertekend over
+ *  (request-target), host, date en digest — precies wat verifyRequest eist. */
+function ondertekendVerzoek({ keyId = KEY_ID, body = { type: 'Follow', actor: ACTOR }, date = new Date().toUTCString(), key = privateKey } = {}) {
+  const raw = Buffer.from(JSON.stringify(body));
+  const digest = 'SHA-256=' + crypto.createHash('sha256').update(raw).digest('base64');
+  const host = 'klonkt.test';
+  const target = 'post /ap/users/me/inbox';
+  const headers = '(request-target) host date digest';
+  const line = [
+    `(request-target): ${target}`,
+    `host: ${host}`,
+    `date: ${date}`,
+    `digest: ${digest}`,
+  ].join('\n');
+  const sig = crypto.sign('sha256', Buffer.from(line), key).toString('base64');
+  return {
+    method: 'POST',
+    originalUrl: '/ap/users/me/inbox',
+    rawBody: raw,
+    body,
+    ip: '203.0.113.9',
+    headers: {
+      host, date, digest,
+      signature: `keyId="${keyId}",headers="${headers}",signature="${sig}"`,
+    },
+  };
+}
+
+test('een correct ondertekend verzoek verifieert', async () => {
+  const verified = await AP.verifyRequest(ondertekendVerzoek(), 'me');
+  assert.ok(verified, 'verifyRequest hoort de actor terug te geven');
+  assert.equal(verified.id, ACTOR);
+});
+
+test('en ook zonder aangewezen site (de gedeelde inbox)', async () => {
+  const verified = await AP.verifyRequest(ondertekendVerzoek());
+  assert.ok(verified);
+  assert.equal(verified.id, ACTOR);
+});
+
+test('een actor achter authorized fetch verifieert ook', async () => {
+  // De combinatie die vandaag tweemaal is verbouwd: de sleutel zit achter secure
+  // mode, dus de ophaal moet ondertekend worden voordat verificatie kan slagen.
+  const verified = await AP.verifyRequest(ondertekendVerzoek({ keyId: SECURE_KEY_ID }), 'me');
+  assert.ok(verified, 'de sleutel hoort ondertekend opgehaald te worden');
+  assert.equal(verified.id, SECURE_ACTOR);
+});
+
+test('een verdraaid lichaam breekt de handtekening', async () => {
+  const req = ondertekendVerzoek();
+  req.rawBody = Buffer.from(JSON.stringify({ type: 'Follow', actor: 'https://kwaad.test/users/x' }));
+  assert.equal(await AP.verifyRequest(req, 'me'), null);
+});
+
+test('een oude Date wordt geweigerd (replay)', async () => {
+  const oud = new Date(Date.now() - 25 * 60 * 60 * 1000).toUTCString();
+  assert.equal(await AP.verifyRequest(ondertekendVerzoek({ date: oud }), 'me'), null);
+});
+
+test('ondertekend met een ANDERE sleutel dan de actor publiceert → null', async () => {
+  const ander = crypto.generateKeyPairSync('rsa', {
+    modulusLength: 2048,
+    publicKeyEncoding: { type: 'spki', format: 'pem' },
+    privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+  });
+  assert.equal(await AP.verifyRequest(ondertekendVerzoek({ key: ander.privateKey }), 'me'), null);
+});
+
+test('geen handtekening → null, en geen enkele ophaalpoging', async () => {
+  assert.equal(await AP.verifyRequest({ method: 'POST', originalUrl: '/x', headers: {} }, 'me'), null);
+});
+
+test.after(() => { globalThis.fetch = echteFetch; });
