Index: src/assets/css/guardian.css
===================================================================
--- src/assets/css/guardian.css	(revision 787e0d9e7941c4f9bbfcc32c7fab0921358e31c0)
+++ src/assets/css/guardian.css	(revision f1c50f9e8909d1873e736e05e5b2cb0c167154ba)
@@ -83,2 +83,7 @@
 button.quiet.is-on { color: var(--ok); border-color: var(--ok); }
 button.small { padding: 6px 12px; font-size: .82rem; }
+
+/* Wards' corner (v2 meekijken) */
+.g-card.feed .g-when { color: var(--muted, #888); font-size: .8em; margin-left: auto; }
+.g-card.feed .feed-body { margin-top: 6px; line-height: 1.4; }
+.g-card.feed .feed-body img { max-width: 100%; border-radius: 8px; }
Index: c/assets/css/guardian2.css
===================================================================
--- src/assets/css/guardian2.css	(revision 787e0d9e7941c4f9bbfcc32c7fab0921358e31c0)
+++ 	(revision )
@@ -1,89 +1,0 @@
-/* Guardian PWA (FEP-633c): its own calm, dark surface; the buoy orange is
-   the accent. Standalone: this page never inherits the site theme. */
-:root {
-  --bg: #141a24;
-  --card: #1e2632;
-  --ink: #e8ecf2;
-  --sub: #93a0b4;
-  --line: #2c3646;
-  --accent: #ff6b35;
-  --ok: #4caf7d;
-}
-* { box-sizing: border-box; }
-body {
-  margin: 0;
-  background: var(--bg);
-  color: var(--ink);
-  font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-  padding-bottom: 48px;
-}
-.g-head {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  padding: 16px;
-  background: var(--card);
-  border-bottom: 1px solid var(--line);
-  position: sticky;
-  top: 0;
-  z-index: 2;
-}
-.g-head-txt { flex: 1; }
-.g-head h1 { font-size: 1.15rem; margin: 0; }
-.g-head .g-sub { margin: 0; color: var(--sub); font-size: .78rem; }
-.g-buoy { font-size: 1.7rem; }
-.g-me { color: var(--sub); font-size: .85rem; }
-#site-picker {
-  background: var(--bg); color: var(--ink);
-  border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px;
-}
-main { max-width: 640px; margin: 0 auto; padding: 8px 16px; }
-
-section { padding: 18px 0; border-bottom: 1px solid var(--line); }
-section:last-child { border-bottom: 0; }
-.g-sec-head { display: flex; align-items: center; gap: 8px; }
-.g-sec-head h2 { font-size: 1.02rem; margin: 0; }
-.g-badge {
-  background: var(--accent); color: #fff; font-size: .72rem; font-weight: 700;
-  min-width: 20px; text-align: center; border-radius: 10px; padding: 1px 7px;
-}
-.g-sec-sub { color: var(--sub); font-size: .84rem; margin: 4px 0 12px; }
-.g-empty { color: var(--sub); font-size: .9rem; margin: 6px 0 0; }
-.g-msg { font-size: .85rem; color: var(--ok); margin: 8px 0 0; }
-.g-msg.err { color: #ff7a7a; }
-.g-list { display: flex; flex-direction: column; gap: 8px; }
-
-.g-card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
-.g-card.help { border-left: 3px solid var(--accent); }
-.g-card .row { display: flex; align-items: center; gap: 10px; }
-.g-card .row .grow { flex: 1; min-width: 0; }
-.g-card .who { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.g-card .when { color: var(--sub); font-size: .76rem; white-space: nowrap; }
-.g-card .body { margin-top: 8px; font-size: .92rem; overflow-wrap: anywhere; }
-.g-card .body img { max-width: 100%; border-radius: 8px; margin-top: 6px; }
-.g-link { display: inline-block; margin-top: 8px; color: var(--accent); font-size: .82rem; text-decoration: none; }
-
-.tag { font-size: .72rem; font-weight: 600; padding: 3px 9px; border-radius: 20px; white-space: nowrap; }
-.tag.wait { background: #3a2f1a; color: #e8b04b; }
-.tag.ok { background: #17301f; color: var(--ok); }
-.tag.co { background: #2a1f3a; color: #c39bff; }
-
-#adopt-form { display: flex; gap: 8px; }
-#adopt-form input {
-  flex: 1; min-width: 0;
-  background: var(--bg); color: var(--ink);
-  border: 1px solid var(--line); border-radius: 10px; padding: 11px 12px;
-}
-button {
-  background: var(--accent); color: #fff; border: 0;
-  border-radius: 10px; padding: 11px 18px; font-weight: 600; cursor: pointer;
-}
-button:disabled { opacity: .5; cursor: default; }
-button.quiet { background: transparent; color: var(--sub); border: 1px solid var(--line); font-weight: 500; }
-button.quiet.is-on { color: var(--ok); border-color: var(--ok); }
-button.small { padding: 6px 12px; font-size: .82rem; }
-
-/* Wards' corner (v2 meekijken) */
-.g-card.feed .g-when { color: var(--muted, #888); font-size: .8em; margin-left: auto; }
-.g-card.feed .feed-body { margin-top: 6px; line-height: 1.4; }
-.g-card.feed .feed-body img { max-width: 100%; border-radius: 8px; }
Index: src/assets/js/guardian.js
===================================================================
--- src/assets/js/guardian.js	(revision 787e0d9e7941c4f9bbfcc32c7fab0921358e31c0)
+++ src/assets/js/guardian.js	(revision f1c50f9e8909d1873e736e05e5b2cb0c167154ba)
@@ -114,4 +114,7 @@
       row.appendChild(el('span', 'who grow', handleOf(w.other_uri, w.other_handle)));
       row.appendChild(el('span', 'tag ok', T.active));
+      var wave = el('button', 'small', T.wave || '👋 Wave');
+      wave.addEventListener('click', function () { sendWave(w.other_uri, wave); });
+      row.appendChild(wave);
       var btn = el('button', 'quiet small', T.release);
       btn.addEventListener('click', function () { remove(w.other_uri, btn); });
@@ -123,4 +126,14 @@
   }
 
+  function sendWave(uri, btn) {
+    btn.disabled = true;
+    fetch('/guardian/api/wave', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ ward: uri, site: S.site }),
+    }).then(function (r) { return r.json(); })
+      .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
+      .catch(function () { btn.disabled = false; });
+  }
+
   function remove(uri, btn) {
     btn.disabled = true;
@@ -133,8 +146,73 @@
   function renderAll() { renderHelp(); renderPending(); renderWards(); }
 
+  // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
+  function renderFeed(items) {
+    var list = document.getElementById('feed-list');
+    list.textContent = '';
+    (items || []).forEach(function (p) {
+      var card = el('div', 'g-card feed');
+      var head = el('div', 'row');
+      head.appendChild(el('span', 'who grow', p.author));
+      if (p.published) head.appendChild(el('span', 'g-when', when(p.published)));
+      card.appendChild(head);
+      var body = el('div', 'feed-body');
+      if (p.cw) {
+        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);
+        body.appendChild(d);
+      } else {
+        body.innerHTML = p.content || '';   // server-sanitized HTML (same as Berichten)
+      }
+      card.appendChild(body);
+      list.appendChild(card);
+    });
+    show('feed-section', (items || []).length > 0);
+  }
+
+  function loadFeed() {
+    return fetch('/guardian/api/feed?site=' + encodeURIComponent(S.site))
+      .then(function (r) { return r.json(); })
+      .then(function (f) { if (f && !f.error) renderFeed(f.items); })
+      .catch(function () { /* corner just stays hidden */ });
+  }
+
+  // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
+  function answerFollow(id, decision, btn) {
+    if (btn) btn.disabled = true;
+    fetch('/guardian/api/follow/' + encodeURIComponent(id), {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ decision: decision, site: S.site }),
+    }).then(loadFollowReqs);
+  }
+  function renderFollowReqs(items) {
+    var list = document.getElementById('follow-list');
+    list.textContent = '';
+    (items || []).forEach(function (f) {
+      var card = el('div', 'g-card');
+      var row = el('div', 'row');
+      row.appendChild(el('span', 'who grow', f.follower + '  →  ' + f.ward));
+      var ok = el('button', 'small', T.accept || 'Accept');
+      ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
+      var no = el('button', 'quiet small', T.reject || 'Deny');
+      no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
+      row.appendChild(ok); row.appendChild(no);
+      card.appendChild(row);
+      list.appendChild(card);
+    });
+    show('follow-section', (items || []).length > 0);
+  }
+  function loadFollowReqs() {
+    return fetch('/guardian/api/follow-requests?site=' + encodeURIComponent(S.site))
+      .then(function (r) { return r.json(); })
+      .then(function (f) { if (f && !f.error) renderFollowReqs(f.items); })
+      .catch(function () { /* stays hidden */ });
+  }
+
   function refresh() {
     return fetch('/guardian/api/state?site=' + encodeURIComponent(S.site))
       .then(function (r) { return r.json(); })
-      .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } });
+      .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } })
+      .then(loadFeed).then(loadFollowReqs);
   }
 
@@ -230,5 +308,5 @@
   });
 
-  renderAll(); pushState();
+  renderAll(); pushState(); loadFeed(); loadFollowReqs();
   setInterval(refresh, 45000);   // live-ish while open
   } catch (e) {
Index: c/assets/js/guardian2.js
===================================================================
--- src/assets/js/guardian2.js	(revision 787e0d9e7941c4f9bbfcc32c7fab0921358e31c0)
+++ 	(revision )
@@ -1,315 +1,0 @@
-/* Guardian PWA client (FEP-633c): renders the dashboard, adopts wards, and
-   manages the guardian push channel. No framework, no inline scripts (CSP).
-   All user-facing text comes from state.strings (server i18n). */
-(function () {
-  'use strict';
-  // A crash here used to fail silently (buttons just do nothing). Surface it on
-  // the page AND the console so the cause is visible instead of "everything hangs".
-  function fatal(msg) {
-    try {
-      var b = document.getElementById('g-fatal') || document.createElement('div');
-      b.id = 'g-fatal'; b.className = 'g-msg err';
-      b.style.cssText = 'display:block;margin:12px 0;padding:10px 14px';
-      b.textContent = 'Guardian: ' + msg;
-      var root = document.querySelector('main') || document.body;
-      if (!b.parentNode && root) root.insertBefore(b, root.firstChild);
-    } catch (e) { /* last resort */ }
-    try { console.error('[guardian]', msg); } catch (e) { /* no console */ }
-  }
-  try {
-  var S = JSON.parse(document.getElementById('guardian-state').textContent || '{}');
-  var T = S.strings || {};
-
-  function el(tag, cls, text) {
-    var n = document.createElement(tag);
-    if (cls) n.className = cls;
-    if (text != null) n.textContent = text;
-    return n;
-  }
-  function handleOf(uri, cached) {
-    if (cached && cached.charAt(0) === '@') return cached;   // trust only real @handles
-    try { var u = new URL(uri); return '@' + u.pathname.split('/').filter(Boolean).pop() + '@' + u.host; }
-    catch (e) { return uri; }
-  }
-  function when(s) { return String(s || '').slice(0, 16).replace('T', ' '); }
-  function show(id, on) { document.getElementById(id).hidden = !on; }
-
-  // ── 1. Help requests ───────────────────────────────────────────────────
-  function renderHelp() {
-    var list = document.getElementById('help-list');
-    list.textContent = '';
-    var help = S.help || [];
-    help.forEach(function (h) {
-      var card = el('div', 'g-card help');
-      var row = el('div', 'row');
-      row.appendChild(el('span', 'who grow', h.actor_name || handleOf(h.actor_uri, h.actor_handle)));
-      row.appendChild(el('span', 'when', when(h.published || h.created_at)));
-      card.appendChild(row);
-      var body = el('div', 'body');
-      body.innerHTML = h.content || '';          // sanitized server-side on ingest
-      card.appendChild(body);
-      if (h.note_url) {
-        var a = el('a', 'g-link', T.open || 'open');
-        a.href = h.note_url; a.target = '_blank'; a.rel = 'noopener';
-        card.appendChild(a);
-      }
-      list.appendChild(card);
-    });
-    var badge = document.getElementById('help-count');
-    badge.textContent = help.length; badge.hidden = help.length === 0;
-    show('help-empty', help.length === 0);
-  }
-
-  // ── 3. Offers I am a party to (sent, or a co-guardianship to co-approve) ─
-  function answer(offerId, decision, btn) {
-    if (btn) btn.disabled = true;
-    fetch('/guardian2/offer', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ offer: offerId, answer: decision, site: S.site }),
-    }).then(refresh);
-  }
-  function offerCard(o) {
-    var card = el('div', 'g-card');
-    var row = el('div', 'row');
-    var subject = o['shaer:iAmCandidate']
-      ? handleOf(o['shaer:ward'], o['shaer:wardHandle'])            // my sent offer: about the ward
-      : handleOf(o['shaer:candidate'], o['shaer:candidateHandle']); // co-guard: who wants in
-    row.appendChild(el('span', 'who grow', subject));
-    if (o['shaer:iAmCandidate']) {
-      // My own offer, waiting for the others to accept.
-      row.appendChild(el('span', 'tag wait', T.pending));
-      var rt = el('button', 'quiet small', T.retract);
-      rt.addEventListener('click', function () { answer(o.id, 'reject', rt); });
-      row.appendChild(rt);
-    } else if (o['shaer:needsMyAccept']) {
-      // A co-guardianship offer for a ward I already guard: my call.
-      row.appendChild(el('span', 'tag co', T.coguard));
-      var ac = el('button', 'small', T.accept);
-      ac.addEventListener('click', function () { answer(o.id, 'accept', ac); });
-      var rj = el('button', 'quiet small', T.reject);
-      rj.addEventListener('click', function () { answer(o.id, 'reject', rj); });
-      row.appendChild(ac); row.appendChild(rj);
-    } else {
-      row.appendChild(el('span', 'tag wait', T.awaiting_others));
-    }
-    card.appendChild(row);
-    return card;
-  }
-  function renderPending() {
-    var list = document.getElementById('pending-list');
-    list.textContent = '';
-    var offers = S.offers || [];
-    offers.forEach(function (o) { list.appendChild(offerCard(o)); });
-    show('pending-section', offers.length > 0);
-  }
-
-  // ── 4. Accepted wards ──────────────────────────────────────────────────
-  function renderWards() {
-    var list = document.getElementById('wards-list');
-    list.textContent = '';
-    var wards = S.wards || [];
-    wards.forEach(function (w) {
-      var card = el('div', 'g-card');
-      var row = el('div', 'row');
-      row.appendChild(el('span', 'who grow', handleOf(w.other_uri, w.other_handle)));
-      row.appendChild(el('span', 'tag ok', T.active));
-      var wave = el('button', 'small', T.wave || '👋 Wave');
-      wave.addEventListener('click', function () { sendWave(w.other_uri, wave); });
-      row.appendChild(wave);
-      var btn = el('button', 'quiet small', T.release);
-      btn.addEventListener('click', function () { remove(w.other_uri, btn); });
-      row.appendChild(btn);
-      card.appendChild(row);
-      list.appendChild(card);
-    });
-    show('wards-empty', wards.length === 0);
-  }
-
-  function sendWave(uri, btn) {
-    btn.disabled = true;
-    fetch('/guardian2/api/wave', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ ward: uri, site: S.site }),
-    }).then(function (r) { return r.json(); })
-      .then(function (j) { btn.disabled = false; btn.textContent = (j && j.ok) ? (T.waved || '👋 sent') : (T.wave || '👋 Wave'); })
-      .catch(function () { btn.disabled = false; });
-  }
-
-  function remove(uri, btn) {
-    btn.disabled = true;
-    fetch('/guardian2/wards/remove', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ uri: uri, site: S.site }),
-    }).then(refresh);
-  }
-
-  function renderAll() { renderHelp(); renderPending(); renderWards(); }
-
-  // ── 0. Wards' corner: read-only feed of your wards' posts ───────────────
-  function renderFeed(items) {
-    var list = document.getElementById('feed-list');
-    list.textContent = '';
-    (items || []).forEach(function (p) {
-      var card = el('div', 'g-card feed');
-      var head = el('div', 'row');
-      head.appendChild(el('span', 'who grow', p.author));
-      if (p.published) head.appendChild(el('span', 'g-when', when(p.published)));
-      card.appendChild(head);
-      var body = el('div', 'feed-body');
-      if (p.cw) {
-        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);
-        body.appendChild(d);
-      } else {
-        body.innerHTML = p.content || '';   // server-sanitized HTML (same as Berichten)
-      }
-      card.appendChild(body);
-      list.appendChild(card);
-    });
-    show('feed-section', (items || []).length > 0);
-  }
-
-  function loadFeed() {
-    return fetch('/guardian2/api/feed?site=' + encodeURIComponent(S.site))
-      .then(function (r) { return r.json(); })
-      .then(function (f) { if (f && !f.error) renderFeed(f.items); })
-      .catch(function () { /* corner just stays hidden */ });
-  }
-
-  // ── 0b. Follow requests on your wards (§5.3) ────────────────────────────
-  function answerFollow(id, decision, btn) {
-    if (btn) btn.disabled = true;
-    fetch('/guardian2/api/follow/' + encodeURIComponent(id), {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ decision: decision, site: S.site }),
-    }).then(loadFollowReqs);
-  }
-  function renderFollowReqs(items) {
-    var list = document.getElementById('follow-list');
-    list.textContent = '';
-    (items || []).forEach(function (f) {
-      var card = el('div', 'g-card');
-      var row = el('div', 'row');
-      row.appendChild(el('span', 'who grow', f.follower + '  →  ' + f.ward));
-      var ok = el('button', 'small', T.accept || 'Accept');
-      ok.addEventListener('click', function () { answerFollow(f.id, 'approve', ok); });
-      var no = el('button', 'quiet small', T.reject || 'Deny');
-      no.addEventListener('click', function () { answerFollow(f.id, 'reject', no); });
-      row.appendChild(ok); row.appendChild(no);
-      card.appendChild(row);
-      list.appendChild(card);
-    });
-    show('follow-section', (items || []).length > 0);
-  }
-  function loadFollowReqs() {
-    return fetch('/guardian2/api/follow-requests?site=' + encodeURIComponent(S.site))
-      .then(function (r) { return r.json(); })
-      .then(function (f) { if (f && !f.error) renderFollowReqs(f.items); })
-      .catch(function () { /* stays hidden */ });
-  }
-
-  function refresh() {
-    return fetch('/guardian2/api/state?site=' + encodeURIComponent(S.site))
-      .then(function (r) { return r.json(); })
-      .then(function (s) { if (s && !s.error) { S = s; T = s.strings || T; renderAll(); } })
-      .then(loadFeed).then(loadFollowReqs);
-  }
-
-  // ── 2. Adopt ───────────────────────────────────────────────────────────
-  var form = document.getElementById('adopt-form');
-  var input = document.getElementById('adopt-handle');
-  var adoptBtn = document.getElementById('adopt-btn');
-  var msg = document.getElementById('adopt-msg');
-  function setMsg(text, isErr) { msg.hidden = false; msg.className = 'g-msg' + (isErr ? ' err' : ''); msg.textContent = text; }
-
-  form.addEventListener('submit', function (ev) {
-    ev.preventDefault();
-    var handle = input.value.trim();
-    if (!handle) return;
-    adoptBtn.disabled = true;
-    setMsg(T.sending || '…', false);
-    fetch('/guardian2/adopt', {
-      method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ handle: handle, site: S.site }),
-    }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
-      .then(function (res) {
-        adoptBtn.disabled = false;
-        if (res.ok) {
-          input.value = '';
-          // Always refresh: the offer is recorded even if delivery is still
-          // in flight. Show it under "Verzonden aanvragen".
-          setMsg(res.j.delivered === false ? T.sent_retry : T.sent, false);
-          refresh();
-        } else {
-          setMsg((res.j.error === 'not_found' ? T.not_found : T.failed) , true);
-        }
-      })
-      .catch(function () { adoptBtn.disabled = false; setMsg(T.network, true); });
-  });
-
-  // ── Site picker ────────────────────────────────────────────────────────
-  var picker = document.getElementById('site-picker');
-  if (picker) picker.addEventListener('change', function () {
-    location.href = '/guardian?site=' + encodeURIComponent(picker.value);
-  });
-
-  // ── 5. Push ────────────────────────────────────────────────────────────
-  var toggle = document.getElementById('push-toggle');
-  var pmsg = document.getElementById('push-msg');
-  function pushState() {
-    if (!('serviceWorker' in navigator) || !('PushManager' in window)) { toggle.disabled = true; return; }
-    navigator.serviceWorker.register('/sw.js').catch(function () {});
-    navigator.serviceWorker.ready
-      .then(function (reg) { return reg.pushManager.getSubscription(); })
-      .then(function (sub) {
-        toggle.textContent = sub ? toggle.dataset.onLabel : toggle.dataset.offLabel;
-        toggle.dataset.subscribed = sub ? '1' : '';
-        toggle.classList.toggle('is-on', !!sub);
-      });
-  }
-  function urlB64(base64) {
-    var pad = '='.repeat((4 - (base64.length % 4)) % 4);
-    var b = (base64 + pad).replace(/-/g, '+').replace(/_/g, '/');
-    var raw = atob(b); var arr = new Uint8Array(raw.length);
-    for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
-    return arr;
-  }
-  toggle.addEventListener('click', function () {
-    pmsg.hidden = true;
-    navigator.serviceWorker.ready.then(function (reg) {
-      if (toggle.dataset.subscribed) {
-        reg.pushManager.getSubscription().then(function (sub) {
-          if (!sub) return;
-          fetch('/push/unsubscribe', {
-            method: 'POST', headers: { 'Content-Type': 'application/json' },
-            body: JSON.stringify({ endpoint: sub.endpoint }),
-          }).then(function () { return sub.unsubscribe(); }).then(pushState);
-        });
-        return;
-      }
-      fetch('/push/vapid').then(function (r) { return r.json(); }).then(function (v) {
-        if (!v.publicKey) throw new Error('no key');
-        return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlB64(v.publicKey) });
-      }).then(function (sub) {
-        return fetch('/push/subscribe', {
-          method: 'POST', headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({
-            subscription: sub.toJSON(),
-            alerts: { help: 1, guardian: 1, dm: 1, follow: 0, reply: 0, like: 0, boost: 0 },
-            uaLabel: 'Guardian PWA',
-          }),
-        });
-      }).then(pushState).catch(function (e) {
-        pmsg.hidden = false; pmsg.className = 'g-msg err';
-        pmsg.textContent = (T.push_unavailable || 'Push unavailable') + ': ' + e.message;
-      });
-    });
-  });
-
-  renderAll(); pushState(); loadFeed(); loadFollowReqs();
-  setInterval(refresh, 45000);   // live-ish while open
-  } catch (e) {
-    fatal((e && e.message) || String(e));
-  }
-})();
Index: src/routes/guardian.js
===================================================================
--- src/routes/guardian.js	(revision 787e0d9e7941c4f9bbfcc32c7fab0921358e31c0)
+++ src/routes/guardian.js	(revision f1c50f9e8909d1873e736e05e5b2cb0c167154ba)
@@ -10,4 +10,6 @@
  */
 import express from 'express';
+import crypto from 'crypto';
+import bcrypt from 'bcryptjs';
 import path from 'path';
 import { fileURLToPath } from 'url';
@@ -38,5 +40,8 @@
     'pending', 'active', 'retract', 'release', 'open', 'push_unavailable',
     'accept', 'reject', 'complete', 'awaiting_others', 'coguard'];
-  return Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
+  const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
+  s.wave = i18nT(L, 'guardian.wave');
+  s.waved = i18nT(L, 'guardian.waved');
+  return s;
 }
 
@@ -84,4 +89,122 @@
   if (!site) return res.status(404).json({ error: 'no_site' });
   res.json(dashboardState(site, resolveLang(req)));
+});
+
+// ── Meekijken (FEP-633c §5, interop-hoofdroute): a committed guardian FOLLOWS
+//    its wards, so their posts (incl. followers-only) are DELIVERED to the
+//    guardian's inbox → timeline. The follow is the mechanism; no new fetch.
+//    First contact also backfills the ward's recent PUBLIC posts as a cold
+//    start so the corner is not empty before delivery catches up.
+function ensureWardConnections(site) {
+  let wards;
+  try { wards = Guardianship.listWards(site.slug); } catch { return; }
+  for (const w of wards) {
+    const already = db.prepare('SELECT 1 FROM ap_following WHERE slug = ? AND actor_uri = ?')
+      .get(site.slug, w.other_uri);
+    if (already) continue;
+    // Follow (guardian's server auto-accepts today; §5.3 gating is a later fase).
+    AP.followActor(site, w.other_uri).catch(() => { /* retried by the queue */ });
+    // Cold start: pull recent public posts now so oma sees something at once.
+    AP.backfillFromOutbox(site.slug, w.other_uri).catch(() => { /* best-effort */ });
+  }
+}
+
+// ── The wards' corner: your wards' posts, read-only. No reply, no share; a
+//    guardian watches, it does not publish (Robins besluit).
+router.get('/api/feed', requireAuth, (req, res) => {
+  const site = siteForUser(req);
+  if (!site) return res.status(404).json({ error: 'no_site' });
+  ensureWardConnections(site);
+  const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
+  // Only show the wards you actually guard (the timeline can hold more).
+  const items = AP.getTimeline(site.slug, 60, 0)
+    .filter((p) => wardUris.has(p.author_uri))
+    .map((p) => ({
+      id: p.id,
+      author: p.author_handle || p.author_name || p.author_uri,
+      authorName: p.author_name,
+      authorIcon: p.author_icon,
+      content: p.content,
+      url: p.url,
+      published: p.published || p.created_at,
+      cw: p.cw || null,
+      media: p.media_json ? JSON.parse(p.media_json) : [],
+    }));
+  res.json({ items, following: wardUris.size });
+});
+
+// ── Follow-gating (FEP-633c §5.3): pending follows on MY wards, for me to
+//    approve. Ward and guardian are co-located on the family Klonkt here, so
+//    the guardian reads its wards' pending follows locally.
+function wardSlugsOf(site) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  return Guardianship.listWards(site.slug)
+    .map((w) => (w.other_uri.startsWith(base) ? w.other_uri.split('/').pop() : null))
+    .filter(Boolean);
+}
+
+router.get('/api/follow-requests', requireAuth, (req, res) => {
+  const site = siteForUser(req);
+  if (!site) return res.status(404).json({ error: 'no_site' });
+  const items = [];
+  const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
+  // Local wards (guardian co-located): read the pending follows directly.
+  for (const wardSlug of wardSlugsOf(site)) {
+    for (const f of Guardianship.follows.listForWard(wardSlug)) {
+      items.push({ id: f.id, ward: `@${wardSlug}@${host}`, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, remote: false, created: f.created_at });
+    }
+  }
+  // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
+  for (const rev of Guardianship.follows.listReviews(site.slug)) {
+    const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
+    items.push({ id: rev.id, ward: wardName, follower: rev.follower_handle || rev.follower_uri, followerIcon: rev.follower_icon, remote: true, created: rev.created_at });
+  }
+  res.json({ items });
+});
+
+router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
+  const site = siteForUser(req);
+  if (!site) return res.status(404).json({ error: 'no_site' });
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const me = AP.actorId(base, site.slug);
+  const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
+
+  // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
+  // which tallies quorum and returns the Accept(Follow) to the follower.
+  const review = Guardianship.follows.getReview(site.slug, req.params.id);
+  if (review) {
+    try { await AP.sendFollowDecision(site, review, decision); }
+    catch { return res.status(502).json({ error: 'delivery' }); }
+    Guardianship.follows.removeReview(site.slug, req.params.id);
+    return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
+  }
+
+  // Local ward: decide directly (quorum on this instance).
+  const pending = Guardianship.follows.getPending(req.params.id);
+  if (!pending) return res.status(404).json({ error: 'gone' });
+  const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
+  if (!guardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
+  const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
+  try {
+    if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
+    else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
+  } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
+  res.json({ ok: true, outcome: r.outcome });
+});
+
+// ── Wave (FEP-633c §5, shaer:wave): a gentle "thinking of you" from a
+//    guardian to a ward. A private direct note, never a feed post. Warmth
+//    without publishing (Robins besluit).
+router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
+  const site = siteForUser(req);
+  if (!site) return res.status(404).json({ error: 'no_site' });
+  const wardUri = String(req.body?.ward || '').trim();
+  // Only wave at a ward you actually guard.
+  const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
+  if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
+  const text = String(req.body?.text || '').trim().slice(0, 200) || '👋 thinking of you';
+  const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
+  if (!r) return res.status(502).json({ error: 'delivery' });
+  res.json({ ok: true, delivered: r.delivered });
 });
 
@@ -181,3 +304,65 @@
 });
 
+// ── Losse guardians (Guardian 2): uitnodigen en aansluiten ───────────────
+// De familie nodigt oma uit; zij kiest naam + wachtwoord en heeft daarmee een
+// guardian-only account: user + minimale site (guardian_only=1). Alles wat al
+// per slug werkt (actor, inbox, offers, push, deze PWA) werkt dan meteen.
+
+router.post('/invite', requireAuth, (req, res) => {
+  const token = crypto.randomBytes(16).toString('base64url');
+  db.prepare('INSERT INTO ap_guardian_invites (token, created_by) VALUES (?,?)')
+    .run(token, req.session.user.id);
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  const url = `${base}/guardian/join/${token}`;
+  res.send(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;max-width:480px;margin:40px auto">
+    <h2>Invite a guardian</h2>
+    <p>Share this link. It lets one person create a guardian account here:</p>
+    <p><a href="${url}">${url}</a></p>
+    <p><a href="/guardian">Back</a></p></body>`);
+});
+
+function joinForm(token, error) {
+  return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+  <body style="font-family:sans-serif;max-width:420px;margin:40px auto">
+  <h2>Become a guardian</h2>
+  <p>Watch over someone you care about. Pick a name and a password; that is all.</p>
+  ${error ? `<p style="color:#b00">${error}</p>` : ''}
+  <form method="post" action="/guardian/join/${token}">
+    <p><input name="name" placeholder="your name (grandma)" required pattern="[a-z0-9_-]{1,32}"
+       style="width:100%;padding:10px" autocapitalize="none"></p>
+    <p><input name="password" type="password" placeholder="password" required minlength="8"
+       style="width:100%;padding:10px"></p>
+    <p><button style="width:100%;padding:12px">Create my guardian account</button></p>
+  </form></body>`;
+}
+
+router.get('/join/:token', (req, res) => {
+  const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
+    .get(req.params.token);
+  if (!inv) return res.status(404).send('This invite is no longer valid.');
+  res.send(joinForm(req.params.token));
+});
+
+router.post('/join/:token', express.urlencoded({ extended: false }), (req, res) => {
+  const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
+    .get(req.params.token);
+  if (!inv) return res.status(404).send('This invite is no longer valid.');
+  const name = String(req.body.name || '').trim().toLowerCase();
+  const password = String(req.body.password || '');
+  if (!/^[a-z0-9_-]{1,32}$/.test(name)) return res.status(400).send(joinForm(req.params.token, 'Only lowercase letters, digits, - and _.'));
+  if (password.length < 8) return res.status(400).send(joinForm(req.params.token, 'Password: at least 8 characters.'));
+  if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(name) || db.prepare('SELECT 1 FROM users WHERE username = ?').get(name)) {
+    return res.status(409).send(joinForm(req.params.token, 'That name is taken, pick another.'));
+  }
+  const userId = crypto.randomUUID();
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
+    .run(userId, name, `${name}@guardian.invalid`, bcrypt.hashSync(password, 10), 'member');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, guardian_only) VALUES (?,?,?,?,0,1)')
+    .run(crypto.randomUUID(), name, name, userId);
+  db.prepare('UPDATE ap_guardian_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE token = ?')
+    .run(userId, req.params.token);
+  req.session.user = { id: userId, username: name, role: 'member' };
+  res.redirect('/guardian');
+});
+
 export default router;
Index: c/routes/guardian2.js
===================================================================
--- src/routes/guardian2.js	(revision 787e0d9e7941c4f9bbfcc32c7fab0921358e31c0)
+++ 	(revision )
@@ -1,368 +1,0 @@
-/**
- * The Guardian PWA (FEP-633c): a separate, installable corner of Klonkt for
- * guardians. One place to add and manage wards, a message centre for
- * incoming help requests and adoption traffic, and its own push channel
- * (alert types 'help' and 'guardian', web-push slice reused).
- *
- * Everything is scoped to a site the logged-in user OWNS: the guardian acts
- * as one of their own actors (?site=slug picks one when they own several).
- * Views carry no inline scripts (CSP): logic lives in /assets/js/guardian.js.
- */
-import express from 'express';
-import crypto from 'crypto';
-import bcrypt from 'bcryptjs';
-import path from 'path';
-import { fileURLToPath } from 'url';
-import db from '../config/database.js';
-import { requireAuth } from '../middleware/auth.js';
-import AP from '../services/ActivityPubService.js';
-import * as Guardianship from '../services/guardianship/index.js';
-import { t as i18nT, resolveLang } from '../services/i18n.js';
-import { injectCspNonce } from '../middleware/render.js';
-
-const router = express.Router();
-const __dir = path.dirname(fileURLToPath(import.meta.url));
-
-/** The acting site: ?site=slug when owned, else the user's first site. */
-function siteForUser(req) {
-  const userId = req.session.user.id;
-  const want = String(req.query.site || req.body?.site || '').trim();
-  if (want) {
-    const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
-    if (s) return s;
-  }
-  return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
-}
-
-/** Everything the dashboard shows, one shape for page and API. */
-function uiStrings(L) {
-  const keys = ['sent', 'sent_retry', 'sending', 'not_found', 'failed', 'network',
-    'pending', 'active', 'retract', 'release', 'open', 'push_unavailable',
-    'accept', 'reject', 'complete', 'awaiting_others', 'coguard'];
-  const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
-  s.wave = i18nT(L, 'guardian2.wave');
-  s.waved = i18nT(L, 'guardian2.waved');
-  return s;
-}
-
-function dashboardState(site, L) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  const help = db.prepare(
-    `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at
-     FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
-  ).all(site.slug);
-  return {
-    site: site.slug,
-    me,
-    wards: Guardianship.listWards(site.slug),               // committed wards
-    offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
-    help,
-    strings: uiStrings(L),
-  };
-}
-
-// ── The PWA page ─────────────────────────────────────────────────────────
-router.get('/', requireAuth, (req, res) => {
-  const site = siteForUser(req);
-  const L = resolveLang(req);
-  if (!site) return res.status(404).send('No site for this account.');
-  const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
-  // This standalone PWA page is rendered directly (not through renderPage), so
-  // the CSP nonce must be injected here — otherwise strict-dynamic blocks
-  // guardian.js and the whole dashboard is dead (buttons do nothing).
-  res.render('pages/guardian2', {
-    state: dashboardState(site, L),
-    sites,
-    lang: L,
-    t: (k, v) => i18nT(L, k, v),
-    cspNonce: res.locals.cspNonce,
-  }, (err, html) => {
-    if (err) { console.error('[guardian] render error', err); return res.status(500).send('Internal Server Error'); }
-    res.send(injectCspNonce(html, res.locals.cspNonce));
-  });
-});
-
-// ── JSON state for refreshes ─────────────────────────────────────────────
-router.get('/api/state', requireAuth, (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  res.json(dashboardState(site, resolveLang(req)));
-});
-
-// ── Meekijken (FEP-633c §5, interop-hoofdroute): a committed guardian FOLLOWS
-//    its wards, so their posts (incl. followers-only) are DELIVERED to the
-//    guardian's inbox → timeline. The follow is the mechanism; no new fetch.
-//    First contact also backfills the ward's recent PUBLIC posts as a cold
-//    start so the corner is not empty before delivery catches up.
-function ensureWardConnections(site) {
-  let wards;
-  try { wards = Guardianship.listWards(site.slug); } catch { return; }
-  for (const w of wards) {
-    const already = db.prepare('SELECT 1 FROM ap_following WHERE slug = ? AND actor_uri = ?')
-      .get(site.slug, w.other_uri);
-    if (already) continue;
-    // Follow (guardian's server auto-accepts today; §5.3 gating is a later fase).
-    AP.followActor(site, w.other_uri).catch(() => { /* retried by the queue */ });
-    // Cold start: pull recent public posts now so oma sees something at once.
-    AP.backfillFromOutbox(site.slug, w.other_uri).catch(() => { /* best-effort */ });
-  }
-}
-
-// ── The wards' corner: your wards' posts, read-only. No reply, no share; a
-//    guardian watches, it does not publish (Robins besluit).
-router.get('/api/feed', requireAuth, (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  ensureWardConnections(site);
-  const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
-  // Only show the wards you actually guard (the timeline can hold more).
-  const items = AP.getTimeline(site.slug, 60, 0)
-    .filter((p) => wardUris.has(p.author_uri))
-    .map((p) => ({
-      id: p.id,
-      author: p.author_handle || p.author_name || p.author_uri,
-      authorName: p.author_name,
-      authorIcon: p.author_icon,
-      content: p.content,
-      url: p.url,
-      published: p.published || p.created_at,
-      cw: p.cw || null,
-      media: p.media_json ? JSON.parse(p.media_json) : [],
-    }));
-  res.json({ items, following: wardUris.size });
-});
-
-// ── Follow-gating (FEP-633c §5.3): pending follows on MY wards, for me to
-//    approve. Ward and guardian are co-located on the family Klonkt here, so
-//    the guardian reads its wards' pending follows locally.
-function wardSlugsOf(site) {
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  return Guardianship.listWards(site.slug)
-    .map((w) => (w.other_uri.startsWith(base) ? w.other_uri.split('/').pop() : null))
-    .filter(Boolean);
-}
-
-router.get('/api/follow-requests', requireAuth, (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const items = [];
-  const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
-  // Local wards (guardian co-located): read the pending follows directly.
-  for (const wardSlug of wardSlugsOf(site)) {
-    for (const f of Guardianship.follows.listForWard(wardSlug)) {
-      items.push({ id: f.id, ward: `@${wardSlug}@${host}`, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, remote: false, created: f.created_at });
-    }
-  }
-  // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
-  for (const rev of Guardianship.follows.listReviews(site.slug)) {
-    const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
-    items.push({ id: rev.id, ward: wardName, follower: rev.follower_handle || rev.follower_uri, followerIcon: rev.follower_icon, remote: true, created: rev.created_at });
-  }
-  res.json({ items });
-});
-
-router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
-
-  // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
-  // which tallies quorum and returns the Accept(Follow) to the follower.
-  const review = Guardianship.follows.getReview(site.slug, req.params.id);
-  if (review) {
-    try { await AP.sendFollowDecision(site, review, decision); }
-    catch { return res.status(502).json({ error: 'delivery' }); }
-    Guardianship.follows.removeReview(site.slug, req.params.id);
-    return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
-  }
-
-  // Local ward: decide directly (quorum on this instance).
-  const pending = Guardianship.follows.getPending(req.params.id);
-  if (!pending) return res.status(404).json({ error: 'gone' });
-  const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
-  if (!guardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
-  const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
-  try {
-    if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
-    else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
-  } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
-  res.json({ ok: true, outcome: r.outcome });
-});
-
-// ── Wave (FEP-633c §5, shaer:wave): a gentle "thinking of you" from a
-//    guardian to a ward. A private direct note, never a feed post. Warmth
-//    without publishing (Robins besluit).
-router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const wardUri = String(req.body?.ward || '').trim();
-  // Only wave at a ward you actually guard.
-  const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
-  if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
-  const text = String(req.body?.text || '').trim().slice(0, 200) || '👋 thinking of you';
-  const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
-  if (!r) return res.status(502).json({ error: 'delivery' });
-  res.json({ ok: true, delivered: r.delivered });
-});
-
-// ── Adopt a ward: handle → resolve → C2S Offer through the same pipeline
-//    the Shaer apps use (one path, one behavior).
-router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const handle = String(req.body?.handle || '').trim();
-  if (!handle) return res.status(400).json({ error: 'empty_handle' });
-  const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
-  if (!wardUri) return res.status(404).json({ error: 'not_found' });   // the handle does not resolve to an account
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const me = AP.actorId(base, site.slug);
-  const r = await AP.ingestOutboxActivity(site, req.session.user, {
-    type: 'Offer',
-    object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
-  });
-  // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
-  // offer is recorded and delivery is retried in the background.
-  if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
-  res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
-});
-
-// ── Answer an offer (co-guardian accept/reject, or the candidate's final
-//    "complete"). All three are a C2S Accept/Reject on the offer id; the
-//    handshake module decides when it commits (§3.1).
-router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const offerId = String(req.body?.offer || '').trim();
-  const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
-  if (!offerId) return res.status(400).json({ error: 'empty_offer' });
-  const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
-  if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
-  res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
-});
-
-// ── PWA assets served no-cache, so an update is never masked by the 1-year
-//    /assets cache or a stuck install (that was the whole "nothing works after
-//    a deploy" bug). Small files; the browser revalidates and gets a 304 when
-//    unchanged, the fresh file when changed.
-function pwaAsset(rel, type) {
-  return (req, res) => {
-    res.set('Cache-Control', 'no-cache');
-    res.type(type);
-    res.sendFile(path.join(__dir, '..', 'assets', rel));
-  };
-}
-router.get('/app.js', pwaAsset('js/guardian2.js', 'application/javascript'));
-router.get('/app.css', pwaAsset('css/guardian2.css', 'text/css'));
-
-// ── Manage: release a committed ward (local Undo; federation is Fase 4). ──
-router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
-  const site = siteForUser(req);
-  if (!site) return res.status(404).json({ error: 'no_site' });
-  const uri = String(req.body?.uri || '').trim();
-  if (!uri) return res.status(400).json({ error: 'empty_uri' });
-  Guardianship.removeRelation(site.slug, 'guardian', uri);
-  res.json({ ok: true });
-});
-
-// ── The installable identity: own scope so the Guardian corner installs as
-//    its own app next to the site PWA.
-router.get('/manifest.webmanifest', (req, res) => {
-  const site = res.locals.site;
-  res.set('Cache-Control', 'no-cache');
-  res.json({
-    id: `klonkt-guardian2-${site?.slug || 'guardian'}`,
-    name: 'Klonkt Guardian',
-    short_name: 'Guardian 2',
-    description: 'Ward management and help requests for guardians.',
-    scope: '/guardian2/',
-    start_url: '/guardian?source=pwa',
-    display: 'standalone',
-    display_override: ['standalone', 'minimal-ui'],
-    orientation: 'any',
-    background_color: '#141a24',
-    theme_color: '#ff6b35',
-    lang: site?.language || 'nl',
-    icons: [
-      { src: '/guardian2/icon.svg', sizes: 'any', type: 'image/svg+xml' },
-    ],
-  });
-});
-
-// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
-router.get('/icon.svg', (req, res) => {
-  const svg = `<?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
-  <rect width="64" height="64" rx="14" fill="#ff6b35"/>
-  <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
-</svg>`;
-  res.set('Content-Type', 'image/svg+xml');
-  res.set('Cache-Control', 'public, max-age=86400');
-  res.send(svg);
-});
-
-// ── Losse guardians (Guardian 2): uitnodigen en aansluiten ───────────────
-// De familie nodigt oma uit; zij kiest naam + wachtwoord en heeft daarmee een
-// guardian-only account: user + minimale site (guardian_only=1). Alles wat al
-// per slug werkt (actor, inbox, offers, push, deze PWA) werkt dan meteen.
-
-router.post('/invite', requireAuth, (req, res) => {
-  const token = crypto.randomBytes(16).toString('base64url');
-  db.prepare('INSERT INTO ap_guardian_invites (token, created_by) VALUES (?,?)')
-    .run(token, req.session.user.id);
-  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
-  const url = `${base}/guardian2/join/${token}`;
-  res.send(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;max-width:480px;margin:40px auto">
-    <h2>Invite a guardian</h2>
-    <p>Share this link. It lets one person create a guardian account here:</p>
-    <p><a href="${url}">${url}</a></p>
-    <p><a href="/guardian2">Back</a></p></body>`);
-});
-
-function joinForm(token, error) {
-  return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
-  <body style="font-family:sans-serif;max-width:420px;margin:40px auto">
-  <h2>Become a guardian</h2>
-  <p>Watch over someone you care about. Pick a name and a password; that is all.</p>
-  ${error ? `<p style="color:#b00">${error}</p>` : ''}
-  <form method="post" action="/guardian2/join/${token}">
-    <p><input name="name" placeholder="your name (grandma)" required pattern="[a-z0-9_-]{1,32}"
-       style="width:100%;padding:10px" autocapitalize="none"></p>
-    <p><input name="password" type="password" placeholder="password" required minlength="8"
-       style="width:100%;padding:10px"></p>
-    <p><button style="width:100%;padding:12px">Create my guardian account</button></p>
-  </form></body>`;
-}
-
-router.get('/join/:token', (req, res) => {
-  const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
-    .get(req.params.token);
-  if (!inv) return res.status(404).send('This invite is no longer valid.');
-  res.send(joinForm(req.params.token));
-});
-
-router.post('/join/:token', express.urlencoded({ extended: false }), (req, res) => {
-  const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
-    .get(req.params.token);
-  if (!inv) return res.status(404).send('This invite is no longer valid.');
-  const name = String(req.body.name || '').trim().toLowerCase();
-  const password = String(req.body.password || '');
-  if (!/^[a-z0-9_-]{1,32}$/.test(name)) return res.status(400).send(joinForm(req.params.token, 'Only lowercase letters, digits, - and _.'));
-  if (password.length < 8) return res.status(400).send(joinForm(req.params.token, 'Password: at least 8 characters.'));
-  if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(name) || db.prepare('SELECT 1 FROM users WHERE username = ?').get(name)) {
-    return res.status(409).send(joinForm(req.params.token, 'That name is taken, pick another.'));
-  }
-  const userId = crypto.randomUUID();
-  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
-    .run(userId, name, `${name}@guardian.invalid`, bcrypt.hashSync(password, 10), 'member');
-  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, guardian_only) VALUES (?,?,?,?,0,1)')
-    .run(crypto.randomUUID(), name, name, userId);
-  db.prepare('UPDATE ap_guardian_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE token = ?')
-    .run(userId, req.params.token);
-  req.session.user = { id: userId, username: name, role: 'member' };
-  res.redirect('/guardian2');
-});
-
-export default router;
Index: src/server.js
===================================================================
--- src/server.js	(revision 787e0d9e7941c4f9bbfcc32c7fab0921358e31c0)
+++ src/server.js	(revision f1c50f9e8909d1873e736e05e5b2cb0c167154ba)
@@ -50,5 +50,4 @@
 import pushRoutes from './routes/push.js';
 import guardianRoutes from './routes/guardian.js';
-import guardian2Routes from './routes/guardian2.js';
 import adminMediaRoutes from './routes/admin-media.js';
 import circleRoutes from './routes/circle.js';
@@ -408,6 +407,5 @@
 app.use('/paid', paidRoutes);   // paid-posts patron/passkey flow (before the /:slug catch-all)
 app.use('/push', pushRoutes);   // web-push subscribe/test (before the /:slug catch-all)
-app.use('/guardian', guardianRoutes);   // the Guardian PWA (FEP-633c, before the /:slug catch-all)
-app.use('/guardian2', guardian2Routes); // Guardian v2: losse guardians (guardian-only accounts), groeit hier los van v1
+app.use('/guardian', guardianRoutes);   // the Guardian PWA (FEP-633c): losse guardians, meekijken, follow-gating, wave, invite (was guardian2, v1 verwijderd)
 app.use('/', postsRoutes);
 
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 787e0d9e7941c4f9bbfcc32c7fab0921358e31c0)
+++ src/services/ActivityPubService.js	(revision f1c50f9e8909d1873e736e05e5b2cb0c167154ba)
@@ -1405,5 +1405,5 @@
       // FEP-633c §5.3, modelled on the guardian offer: the ward forwards the
       // gated follow to its guardians for approval. A LOCAL guardian gets a
-      // push and reads /guardian2 directly; a REMOTE guardian gets an
+      // push and reads /guardian directly; a REMOTE guardian gets an
       // Offer(Follow) delivered so its instance stores a copy (same distributed
       // pattern as the adoption offer). On quorum the ward returns Accept(Follow).
@@ -1419,5 +1419,5 @@
         if (isLocal) {
           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: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian2` });
+          pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fi.name || fi.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian` });
         } else {
           fetchActor(g).then((ga) => {
@@ -3009,5 +3009,5 @@
       Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
       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)}/guardian2` });
+      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` });
       stored = true;
     }
@@ -3032,5 +3032,5 @@
 }
 
-// Leg 3: a guardian in /guardian2 decides on a forwarded follow; send the
+// Leg 3: a guardian in /guardian decides on a forwarded follow; send the
 // Accept/Reject back to the ward's inbox (signed by the guardian).
 export async function sendFollowDecision(guardianSite, review, decision) {
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 787e0d9e7941c4f9bbfcc32c7fab0921358e31c0)
+++ src/services/i18n.js	(revision f1c50f9e8909d1873e736e05e5b2cb0c167154ba)
@@ -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.wave_r1': 'Wat leuk!', 'msg.wave_r2': 'Bel me even', 'msg.wave_back': '👋 Terug', 'msg.wave_sent': 'Zwaai verstuurd.', 'guardian2.feed_title': 'Van je wards', 'guardian2.feed_sub': 'Meelezen met wat je wards plaatsen. Alleen kijken.', 'guardian2.follow_title': 'Volgverzoeken', 'guardian2.follow_sub': 'Iemand wil een van je wards volgen. Jij beslist.', 'guardian2.wave': '👋 Zwaai', 'guardian2.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.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.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_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.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.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.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_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.',
     '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.',
@@ -1008,5 +1008,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.wave_r1': 'Lovely!', 'msg.wave_r2': 'Call me', 'msg.wave_back': '👋 Back', 'msg.wave_sent': 'Wave sent.', 'guardian2.feed_title': 'Your wards', 'guardian2.feed_sub': 'Read along with what your wards post. Watch only.', 'guardian2.follow_title': 'Follow requests', 'guardian2.follow_sub': 'Someone wants to follow one of your wards. You decide.', 'guardian2.wave': '👋 Wave', 'guardian2.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.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.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_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.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.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.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_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.',
     '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.',
@@ -1943,5 +1943,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.wave_r1': 'Wie schön!', 'msg.wave_r2': 'Ruf mich an', 'msg.wave_back': '👋 Zurück', 'msg.wave_sent': 'Winken gesendet.', 'guardian2.feed_title': 'Deine Wards', 'guardian2.feed_sub': 'Lies mit, was deine Wards posten. Nur schauen.', 'guardian2.follow_title': 'Follow-Anfragen', 'guardian2.follow_sub': 'Jemand möchte einem deiner Wards folgen. Du entscheidest.', 'guardian2.wave': '👋 Winken', 'guardian2.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.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.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_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.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.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.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_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.',
     '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/guardian.ejs
===================================================================
--- src/views/pages/guardian.ejs	(revision 787e0d9e7941c4f9bbfcc32c7fab0921358e31c0)
+++ src/views/pages/guardian.ejs	(revision f1c50f9e8909d1873e736e05e5b2cb0c167154ba)
@@ -26,7 +26,24 @@
     <span class="g-me" title="<%= t('guardian.acting_as') %>">@<%= state.site %></span>
     <% } %>
-  </header>
+    <form method="post" action="/guardian/invite" style="display:inline">
+    <button class="quiet small" type="submit">Invite a guardian</button>
+  </form>
+</header>
 
   <main>
+    <!-- 0. Wards' corner: meelezen met je wards (read-only). -->
+    <section id="feed-section" hidden>
+      <div class="g-sec-head"><h2><%= t('guardian.feed_title') %></h2></div>
+      <p class="g-sec-sub"><%= t('guardian.feed_sub') %></p>
+      <div id="feed-list" class="g-list"></div>
+    </section>
+
+    <!-- 0b. Follow-verzoeken op je wards (FEP-633c §5.3). -->
+    <section id="follow-section" hidden>
+      <div class="g-sec-head"><h2><%= t('guardian.follow_title') %></h2></div>
+      <p class="g-sec-sub"><%= t('guardian.follow_sub') %></p>
+      <div id="follow-list" class="g-list"></div>
+    </section>
+
     <!-- 1. Berichtencentrum: hulpverzoeken. Dat is waar dit voor bestaat. -->
     <section id="help-section">
Index: c/views/pages/guardian2.ejs
===================================================================
--- src/views/pages/guardian2.ejs	(revision 787e0d9e7941c4f9bbfcc32c7fab0921358e31c0)
+++ 	(revision )
@@ -1,98 +1,0 @@
-<!DOCTYPE html>
-<html lang="<%= lang %>">
-<head>
-  <meta charset="utf-8">
-  <meta name="viewport" content="width=device-width, initial-scale=1">
-  <title><%= t('guardian.app_name') %> 2</title>
-  <link rel="manifest" href="/guardian2/manifest.webmanifest">
-  <link rel="icon" href="/guardian2/icon.svg" type="image/svg+xml">
-  <meta name="theme-color" content="#ff6b35">
-  <link rel="stylesheet" href="/guardian2/app.css">
-</head>
-<body>
-  <header class="g-head">
-    <span class="g-buoy">🛟</span>
-    <div class="g-head-txt">
-      <h1><%= t('guardian.app_name') %></h1>
-      <p class="g-sub"><%= t('guardian.tagline') %></p>
-    </div>
-    <% if (sites.length > 1) { %>
-    <select id="site-picker" aria-label="<%= t('guardian.acting_as') %>">
-      <% for (const s of sites) { %>
-      <option value="<%= s.slug %>" <%= s.slug === state.site ? 'selected' : '' %>>@<%= s.slug %></option>
-      <% } %>
-    </select>
-    <% } else { %>
-    <span class="g-me" title="<%= t('guardian.acting_as') %>">@<%= state.site %></span>
-    <% } %>
-    <form method="post" action="/guardian2/invite" style="display:inline">
-    <button class="quiet small" type="submit">Invite a guardian</button>
-  </form>
-</header>
-
-  <main>
-    <!-- 0. Wards' corner: meelezen met je wards (read-only). -->
-    <section id="feed-section" hidden>
-      <div class="g-sec-head"><h2><%= t('guardian2.feed_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian2.feed_sub') %></p>
-      <div id="feed-list" class="g-list"></div>
-    </section>
-
-    <!-- 0b. Follow-verzoeken op je wards (FEP-633c §5.3). -->
-    <section id="follow-section" hidden>
-      <div class="g-sec-head"><h2><%= t('guardian2.follow_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian2.follow_sub') %></p>
-      <div id="follow-list" class="g-list"></div>
-    </section>
-
-    <!-- 1. Berichtencentrum: hulpverzoeken. Dat is waar dit voor bestaat. -->
-    <section id="help-section">
-      <div class="g-sec-head">
-        <h2><%= t('guardian.help_title') %></h2>
-        <span id="help-count" class="g-badge" hidden></span>
-      </div>
-      <p class="g-sec-sub"><%= t('guardian.help_sub') %></p>
-      <div id="help-list" class="g-list"></div>
-      <p id="help-empty" class="g-empty"><%= t('guardian.help_empty') %></p>
-    </section>
-
-    <!-- 2. Nieuwe ward adopteren. -->
-    <section id="adopt-section">
-      <div class="g-sec-head"><h2><%= t('guardian.adopt_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian.adopt_sub') %></p>
-      <form id="adopt-form" autocomplete="off">
-        <input id="adopt-handle" type="text" inputmode="email" autocomplete="off"
-               placeholder="@kind@server.eu" aria-label="<%= t('guardian.adopt_label') %>">
-        <button type="submit" id="adopt-btn"><%= t('guardian.adopt_btn') %></button>
-      </form>
-      <p id="adopt-msg" class="g-msg" hidden></p>
-    </section>
-
-    <!-- 3. Verzonden aanvragen (nog niet beantwoord). -->
-    <section id="pending-section" hidden>
-      <div class="g-sec-head"><h2><%= t('guardian.pending_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian.pending_sub') %></p>
-      <div id="pending-list" class="g-list"></div>
-    </section>
-
-    <!-- 4. Mijn wards (geaccepteerd). -->
-    <section id="wards-section">
-      <div class="g-sec-head"><h2><%= t('guardian.wards_title') %></h2></div>
-      <div id="wards-list" class="g-list"></div>
-      <p id="wards-empty" class="g-empty"><%= t('guardian.wards_empty') %></p>
-    </section>
-
-    <!-- 5. Meldingen. -->
-    <section id="push-section">
-      <div class="g-sec-head"><h2><%= t('guardian.push_title') %></h2></div>
-      <p class="g-sec-sub"><%= t('guardian.push_sub') %></p>
-      <button id="push-toggle" class="quiet" data-on-label="<%= t('guardian.push_off') %>"
-              data-off-label="<%= t('guardian.push_on') %>"><%= t('guardian.push_on') %></button>
-      <p id="push-msg" class="g-msg" hidden></p>
-    </section>
-  </main>
-
-  <script type="application/json" id="guardian-state"><%- JSON.stringify(state) %></script>
-  <script src="/guardian2/app.js" defer></script>
-</body>
-</html>
