Index: src/assets/js/mod/messages.js
===================================================================
--- src/assets/js/mod/messages.js	(revision 02c623148e6e0650b89c365ef649c21ee484b021)
+++ src/assets/js/mod/messages.js	(revision 2bd31d67a4567197583d1b78c6097c9c4b4558bf)
@@ -126,4 +126,52 @@
   }
 
+  // Zwaaien ter plekke: een seintje is geen paginawissel.
+  //
+  // Het formulier blijft een echt formulier -- zonder JS post het en leidt de
+  // route om, precies als vroeger. Hier onderscheppen we het en sturen het met
+  // fetch, zoals like/boost in de Krant het al deden.
+  //
+  // LET OP DE VERZENDKNOP. Deze formulieren hebben er MEERDERE ("Wat leuk!",
+  // "Hoe gaat het?", "Terug"), elk met een eigen name="text". FormData(f) neemt
+  // de aangeklikte knop NIET mee -- dat doet de browser alleen bij een echte
+  // verzending. Zonder submitter vertrekt er dus een zwaai zonder tekst, en die
+  // weigert de route. Vandaar submitter, met een terugval op de eerste knop.
+  document.addEventListener('submit', function (e) {
+    var f = e.target.closest && e.target.closest('.msg-wave, .msg-quickreply');
+    if (!f || f.dataset.sending) return;
+    e.preventDefault();
+    var knoppen = [].slice.call(f.querySelectorAll('button'));
+    var knop = e.submitter || knoppen[0];
+    if (!knop) return;
+    f.dataset.sending = '1';
+    knoppen.forEach(function (b) { b.disabled = true; });
+    var body = new URLSearchParams();
+    new FormData(f).forEach(function (v, k) { body.append(k, v); });
+    if (knop.name) body.set(knop.name, knop.value);
+    fetch(f.action, {
+      method: 'POST', body: body,
+      headers: { 'X-Requested-With': 'fetch' },
+      credentials: 'same-origin',
+    })
+      .then(function (r) { return r.ok ? r.json() : null; })
+      .then(function (j) {
+        if (!j || !j.ok) throw new Error('geweigerd');
+        // Gelukt: de knoppen maken plaats voor de bevestiging. Zwaaien is
+        // eenmalig -- anders dan een like valt het niet terug te draaien, dus
+        // een aan/uit-knop zou liegen over wat er kan.
+        var klaar = document.createElement('span');
+        klaar.className = 'msg-wave-done';
+        klaar.textContent = '👋 ' + (f.dataset.sent || '');
+        f.replaceChildren(klaar);
+      })
+      .catch(function () {
+        // Mislukt: alles terug zoals het was, zodat een tweede poging kan.
+        delete f.dataset.sending;
+        knoppen.forEach(function (b) { b.disabled = false; });
+        f.classList.add('is-failed');
+        setTimeout(function () { f.classList.remove('is-failed'); }, 600);
+      });
+  });
+
   function start() { reindex(); apply(); wireBookmarklet(); }
   start();
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 02c623148e6e0650b89c365ef649c21ee484b021)
+++ src/routes/posts.js	(revision 2bd31d67a4567197583d1b78c6097c9c4b4558bf)
@@ -973,10 +973,15 @@
   const to = String(req.body.to || '').trim();
   const text = String(req.body.text || '').trim().slice(0, 200);
-  if (!site || !/^https?:\/\//i.test(to) || !text) return res.redirect(back + '?error=quickreply');
+  // Zwaaien is een seintje, en een seintje hoort de pagina niet te herladen.
+  // De module stuurt hem met X-Requested-With: fetch en krijgt JSON terug;
+  // zonder JS blijft het formulier gewoon posten en omleiden.
+  const viaFetch = req.get('X-Requested-With') === 'fetch';
+  const mis = (reden) => (viaFetch ? res.status(400).json({ ok: false, error: reden }) : res.redirect(back + '?error=' + reden));
+  if (!site || !/^https?:\/\//i.test(to) || !text) return mis('quickreply');
   try {
     const r = await ActivityPubService.deliverDirectNote(site, { recipients: [to], text, wave: true });
-    if (r) return res.redirect(back + '?success=wave_sent');
+    if (r) return viaFetch ? res.json({ ok: true }) : res.redirect(back + '?success=wave_sent');
   } catch { /* fall through */ }
-  res.redirect(back + '?error=quickreply');
+  return mis('quickreply');
 });
 
Index: src/views/pages/messages.ejs
===================================================================
--- src/views/pages/messages.ejs	(revision 02c623148e6e0650b89c365ef649c21ee484b021)
+++ src/views/pages/messages.ejs	(revision 2bd31d67a4567197583d1b78c6097c9c4b4558bf)
@@ -162,4 +162,20 @@
   .msg-wave button { line-height: 1.2; }
 
+  /* Zwaaien gebeurt ter plekke (mod/messages.js). Twee toestanden na de klik:
+     gelukt, dan vervangt de bevestiging de knoppen -- en mislukt, dan komen de
+     knoppen terug met een schud, zodat een tweede poging voor de hand ligt.
+     De bestaande klasse `nudge` kon hier niet voor dienen: die heeft alleen
+     CSS op authorize-interaction, dus op deze pagina zou hij niets doen. */
+  .msg-wave-done { font-size: .82rem; color: var(--ink-soft, #888); white-space: nowrap; }
+  .msg-wave.is-failed, .msg-quickreply.is-failed { animation: msg-wave-shake .4s ease; }
+  @keyframes msg-wave-shake {
+    0%, 100% { transform: translateX(0); }
+    25% { transform: translateX(-4px); }
+    75% { transform: translateX(4px); }
+  }
+  @media (prefers-reduced-motion: reduce) {
+    .msg-wave.is-failed, .msg-quickreply.is-failed { animation: none; outline: 2px solid var(--accent); }
+  }
+
   @media (max-width: 560px) {
     /* Op een telefoon is 40px avatar per bubbel puur verlies: de naam staat er
Index: src/views/partials/msg-item.ejs
===================================================================
--- src/views/partials/msg-item.ejs	(revision 02c623148e6e0650b89c365ef649c21ee484b021)
+++ src/views/partials/msg-item.ejs	(revision 2bd31d67a4567197583d1b78c6097c9c4b4558bf)
@@ -70,5 +70,5 @@
                   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">
+                <form method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" class="msg-wave" data-sent="<%= t('msg.wave_sent') %>">
                   <input type="hidden" name="to" value="<%= _rt.actorUri %>">
                   <button class="btn small" name="text" value="&#128075;" title="<%= t('msg.wave_back') %>">&#128075;</button>
@@ -167,5 +167,5 @@
             <% } else if ((_t === 'reply' || _t === 'mention' || _t === 'sent') && n.content) { %><div class="msg-content msg-note"><%- include('../partials/note-body', { nb: n }) %></div>
               <% if (_t === 'mention' && n.wave && n.actorUri && canMutate) { %>
-                <form class="msg-quickreply" method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">
+                <form class="msg-quickreply" method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" data-sent="<%= t('msg.wave_sent') %>" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">
                   <input type="hidden" name="to" value="<%= n.actorUri %>">
                   <button class="btn small" name="text" value="<%= t('msg.wave_r1') %>"><%= t('msg.wave_r1') %></button>
Index: test/messages-wave.test.js
===================================================================
--- test/messages-wave.test.js	(revision 2bd31d67a4567197583d1b78c6097c9c4b4558bf)
+++ test/messages-wave.test.js	(revision 2bd31d67a4567197583d1b78c6097c9c4b4558bf)
@@ -0,0 +1,101 @@
+// Zwaaien ter plekke: de route moet twee soorten bezoekers bedienen.
+//
+// Met X-Requested-With: fetch antwoordt hij JSON en blijft de pagina staan;
+// zonder dat blijft het een gewoon formulier dat post en omleidt. Dat tweede
+// pad is geen restant maar de no-JS-weg, en het moet werken blijven.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+
+// De aflevering onderscheppen: deze test gaat over het HTTP-gedrag van de
+// route, niet over federatie. Wat er de deur uit gaat leggen we wel vast.
+const APmod = await import('../src/services/ActivityPubService.js');
+const AP = APmod.default;
+let bezorgd = [];
+let bezorgingLukt = true;
+AP.deliverDirectNote = async (site, opts) => { bezorgd.push(opts); return bezorgingLukt; };
+
+const express = (await import('express')).default;
+const session = (await import('express-session')).default;
+const routes = (await import('../src/routes/posts.js')).default;
+
+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_public, is_primary) VALUES (?,?,?,?,1,1)')
+  .run('s1', 'band', 'De Band', 'u1');
+
+const app = express();
+app.use(session({ secret: 't', resave: false, saveUninitialized: false }));
+app.use((req, res, next) => {
+  req.session.userId = 'u1';
+  req.session.user = { id: 'u1', username: 'u1', role: 'god' };
+  res.locals.site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get();
+  res.locals.siteUrlBase = '';
+  res.locals.user = req.session.user;
+  next();
+});
+app.use(routes);
+const server = app.listen(0);
+await new Promise((r) => server.once('listening', r));
+const base = `http://127.0.0.1:${server.address().port}`;
+
+const zwaai = (velden, headers = {}) => fetch(base + '/messages/quick-reply', {
+  method: 'POST', redirect: 'manual',
+  headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
+  body: new URLSearchParams(velden),
+});
+
+test('met de fetch-header: JSON terug, geen omleiding', async () => {
+  bezorgd = []; bezorgingLukt = true;
+  const r = await zwaai({ to: 'https://elders.example/users/kim', text: '👋' }, { 'X-Requested-With': 'fetch' });
+  assert.equal(r.status, 200);
+  assert.equal((r.headers.get('content-type') || '').split(';')[0], 'application/json');
+  assert.deepEqual(await r.json(), { ok: true });
+  assert.equal(bezorgd.length, 1);
+  assert.equal(bezorgd[0].text, '👋');
+  assert.equal(bezorgd[0].wave, true, 'het blijft een zwaai, geen gewoon bericht');
+});
+
+test('zonder die header blijft het de oude weg: omleiden met wave_sent', async () => {
+  bezorgd = []; bezorgingLukt = true;
+  const r = await zwaai({ to: 'https://elders.example/users/kim', text: 'Wat leuk!' });
+  assert.equal(r.status, 302);
+  assert.match(r.headers.get('location'), /\/messages\?success=wave_sent$/);
+  assert.equal(bezorgd[0].text, 'Wat leuk!', 'de gekozen tekst, niet een vaste');
+});
+
+test('een zwaai zonder tekst wordt geweigerd -- de val bij meerdere verzendknoppen', async () => {
+  // FormData neemt de aangeklikte knop niet mee; vergeet de module de submitter,
+  // dan komt het verzoek hier ZONDER text aan. Dan hoort het te falen, niet een
+  // leeg bericht te versturen.
+  bezorgd = [];
+  const r = await zwaai({ to: 'https://elders.example/users/kim' }, { 'X-Requested-With': 'fetch' });
+  assert.equal(r.status, 400);
+  assert.equal((await r.json()).ok, false);
+  assert.equal(bezorgd.length, 0, 'er mag niets vertrokken zijn');
+});
+
+test('een onbruikbare ontvanger geeft 400 in JSON, en 302 zonder de header', async () => {
+  bezorgd = [];
+  const a = await zwaai({ to: 'geen-uri', text: '👋' }, { 'X-Requested-With': 'fetch' });
+  assert.equal(a.status, 400);
+  const b = await zwaai({ to: 'geen-uri', text: '👋' });
+  assert.equal(b.status, 302);
+  assert.match(b.headers.get('location'), /error=quickreply/);
+  assert.equal(bezorgd.length, 0);
+});
+
+test('mislukte aflevering: geen valse bevestiging', async () => {
+  bezorgd = []; bezorgingLukt = false;
+  const r = await zwaai({ to: 'https://elders.example/users/kim', text: '👋' }, { 'X-Requested-With': 'fetch' });
+  assert.equal(r.status, 400);
+  assert.equal((await r.json()).ok, false);
+});
+
+test.after(() => server.close());
