Index: src/assets/js/mod/chrome.js
===================================================================
--- src/assets/js/mod/chrome.js	(revision 1e172f36d16542632fe17c49029059a6f3b50bba)
+++ src/assets/js/mod/chrome.js	(revision 1e172f36d16542632fe17c49029059a6f3b50bba)
@@ -0,0 +1,42 @@
+// De chrome: alles wat om elke pagina heen staat (shaer-bqr).
+//
+// Dit stond als inline <script> in de partials. Dat kan niet blijven: de
+// CSP-nonce rouleert per verzoek, en de chrome wordt bij ELKE htmx-navigatie
+// out-of-band opnieuw ingevoegd. Zo'n script draagt dan een nonce die het
+// document niet kent en wordt geweigerd -- dus viel de chrome-JS bij de eerste
+// klik binnen de site al weg (shaer-0i6).
+//
+// Nu een module, geladen door de bootstrap in shell.ejs. Die zit in het
+// document zelf, heeft dus wel de goede nonce, en een dynamische import vanuit
+// een vertrouwd script is precies waar 'strict-dynamic' voor is.
+//
+// TWEE REGELS voor alles wat hier bij komt:
+//
+//   GEDELEGEERD  luister op document, nooit op een element dat er nu staat. De
+//                chrome wordt vervangen, dus een vastgehouden verwijzing is na
+//                een navigatie een verwijzing naar iets dat weg is.
+//   IDEMPOTENT   de module kan een tweede keer geladen worden. Een slot op
+//                window voorkomt dat er een tweede stel luisteraars bij komt --
+//                dat is hoe de themaknop ooit twee keer vuurde en dus niets deed.
+//
+// En: GEEN servergegevens in deze code. Interpolatie hoort niet in een statisch
+// bestand; wat de server wil meegeven komt via een data-attribuut op een element.
+
+(function () {
+  'use strict';
+  if (window.__chromeMod) return;
+  window.__chromeMod = true;
+
+  // De zoekknop in de bottom-tab opent de overlay. De overlay zelf wordt bij een
+  // htmx-navigatie vervangen, dus hem hier vasthouden zou na een klik binnen de
+  // site niets meer opleveren: elke keer opnieuw opzoeken.
+  document.addEventListener('click', function (e) {
+    if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return;
+    var o = document.getElementById('search-overlay');
+    if (o) {
+      o.hidden = false;
+      var i = o.querySelector('input');
+      if (i) i.focus();
+    }
+  });
+})();
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision ea138c0c6a4bc5a508027368c44de1a8cdb6a326)
+++ src/config/database.js	(revision 1e172f36d16542632fe17c49029059a6f3b50bba)
@@ -106,4 +106,13 @@
     PRIMARY KEY (guardian_slug, id)
   )`);
+  // Guardianship Fase 2 (shaer-jdb): een doorgestuurde follow-goedkeuring draagt
+  // een RICHTING. Bij een inkomende is de follower iemand anders en de ward het
+  // doel; bij een uitgaande is de ward zelf de follower en staat het doel in het
+  // Follow-object. Zonder deze twee kolommen werd een uitgaande opgeslagen als
+  // "deze ward wil deze ward volgen" en viel het doel weg -- dan valt er niets
+  // zinnigs te tonen, hoe je de wachtrij ook vult.
+  ensureColumn('ap_follow_reviews', 'direction', "TEXT DEFAULT 'incoming'");
+  ensureColumn('ap_follow_reviews', 'target_uri', 'TEXT');
+  ensureColumn('ap_follow_reviews', 'target_handle', 'TEXT');
   ensureColumn('sites', 'profile_photo', 'TEXT');
   ensureColumn('audio_tracks', 'cover_url', 'TEXT');
Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision ea138c0c6a4bc5a508027368c44de1a8cdb6a326)
+++ src/middleware/render.js	(revision 1e172f36d16542632fe17c49029059a6f3b50bba)
@@ -286,6 +286,10 @@
         ? _site.accent : '#e8b04b';
       const _navPalette = (_site && _site.palette) ? _site.palette : 'klonkt';
+      // Welke modules de nieuwe pagina wil (shaer-bqr). De bootstrap in de shell
+      // zet dit op de body en haalt op wat er nieuw bij staat; 'chrome' hoort er
+      // altijd bij, want die komt bij elke navigatie opnieuw binnen.
+      const _navJs = ('chrome ' + (locals.pageJs || '')).trim();
       const triggerJson = JSON.stringify({
-        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette },
+        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs },
         pcmsPostSwap: data.post ? {
           title: data.post.title,
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision ea138c0c6a4bc5a508027368c44de1a8cdb6a326)
+++ src/routes/activitypub.js	(revision 1e172f36d16542632fe17c49029059a6f3b50bba)
@@ -253,5 +253,5 @@
 }
 queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
-queueRoute('follows', (id) => Guardianship.followsCollection(id));
+queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
 // §5.3 turned around (shaer-p729): what this ward has asked to follow, still
 // waiting on its guardians. Owner-only like the rest — who a child wants to
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision ea138c0c6a4bc5a508027368c44de1a8cdb6a326)
+++ src/services/ActivityPubService.js	(revision 1e172f36d16542632fe17c49029059a6f3b50bba)
@@ -4902,5 +4902,18 @@
       const wardDoc = await fetchActor(wardUri).catch(() => null);
       const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
-      Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
+      // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande
+      // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde
+      // hier als "deze ward wil deze ward volgen" met het doel weggegooid.
+      // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het
+      // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden.
+      const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri;
+      const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null;
+      const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null;
+      Guardianship.follows.recordReview(gslug, {
+        id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox,
+        follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo),
+        direction: uitgaand ? 'outgoing' : 'incoming',
+        target: doel || null, targetHandle: dai ? dai.handle : null,
+      });
       const L = pushLang(gslug);
       pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fai.name || fai.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian` });
Index: src/services/guardianship/follows.js
===================================================================
--- src/services/guardianship/follows.js	(revision ea138c0c6a4bc5a508027368c44de1a8cdb6a326)
+++ src/services/guardianship/follows.js	(revision 1e172f36d16542632fe17c49029059a6f3b50bba)
@@ -81,6 +81,7 @@
     _r = {
       ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
-        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at)
-        VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
+        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json,
+         direction, target_uri, target_handle, created_at)
+        VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
       get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
       bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
@@ -91,7 +92,55 @@
 }
 
+/**
+ * De guardian-zijdige kopie van een gate-verzoek op een REMOTE ward.
+ *
+ * `direction` is niet cosmetisch (shaer-jdb). Bij een INKOMENDE is de follower
+ * iemand anders en de ward het doel. Bij een UITGAANDE is de ward zelf de
+ * follower en staat het doel in het Follow-object -- die werd hiervoor
+ * opgeslagen als "deze ward wil deze ward volgen", met het doel weggegooid.
+ */
 export function recordReview(guardianSlug, r) {
-  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null);
+  const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming';
+  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null,
+    r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null);
   return rstmts().get.get(guardianSlug, r.id);
+}
+
+/**
+ * Een openstaande review als wachtrij-item, in dezelfde vorm die de clients al
+ * lezen (offers en outgoing-follows doen het net zo).
+ */
+export function reviewQueueItem(r, me, guardianCount) {
+  // guardianCount blijft WEG als we hem niet kennen. Bij een remote ward wordt
+  // de guardian-set op diens eigen server bijgehouden, en 0 sturen zou lezen als
+  // "dit kind heeft geen guardians" -- het tegenovergestelde van onbekend.
+  const stemmen = (() => {
+    try { return db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(r.id); }
+    catch { return []; }
+  })();
+  const uitgaand = r.direction === 'outgoing';
+  return {
+    id: r.id,
+    type: 'Follow',
+    // Bij een uitgaande is de WARD de volger; bij een inkomende is dat de vreemde.
+    actor: uitgaand ? r.ward_uri : r.follower_uri,
+    object: uitgaand ? (r.target_uri || '') : r.ward_uri,
+    'shaer:direction': uitgaand ? 'outgoing' : 'incoming',
+    'shaer:ward': r.ward_uri,
+    'shaer:target': uitgaand ? (r.target_uri || undefined) : undefined,
+    'shaer:targetHandle': uitgaand ? (r.target_handle || undefined) : undefined,
+    'shaer:follower': uitgaand ? undefined : r.follower_uri,
+    'shaer:followerHandle': uitgaand ? undefined : (r.follower_handle || undefined),
+    'shaer:quorum': 'all',
+    'shaer:approvals': stemmen.filter((x) => x.decision === 'approve').length,
+    'shaer:guardianCount': guardianCount || undefined,
+    'shaer:myVote': stemmen.some((x) => x.guardian_uri === me),
+    published: r.created_at,
+  };
+}
+
+/** De openstaande reviews van een guardian, per richting. */
+export function listReviewsByDirection(guardianSlug, direction) {
+  return listReviews(guardianSlug).filter((r) => (r.direction === 'outgoing' ? 'outgoing' : 'incoming') === direction);
 }
 export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
@@ -102,3 +151,4 @@
   recordPending, getPending, listForWard, decide, remove,
   recordReview, getReview, listReviews, removeReview,
+  listReviewsByDirection, reviewQueueItem,
 };
Index: src/services/guardianship/queues.js
===================================================================
--- src/services/guardianship/queues.js	(revision ea138c0c6a4bc5a508027368c44de1a8cdb6a326)
+++ src/services/guardianship/queues.js	(revision 1e172f36d16542632fe17c49029059a6f3b50bba)
@@ -6,5 +6,5 @@
  *  - offers:  pending handshake offers where I am a party (§3), with the full
  *             accept tally so the client shows the right action
- *  - follows: pending gated follows for my wards (§5.3) — Fase 2, empty for now
+ *  - follows: pending gated follows ON my wards (§5.3), Fase 2 (shaer-jdb)
  *  - wards:   my committed wards
  */
@@ -13,4 +13,5 @@
 import * as availability from './availability.js';
 import * as outgoing from './outgoing.js';
+import * as follows from './follows.js';
 import * as handshake from './handshake.js';
 
@@ -35,12 +36,57 @@
 }
 
-/** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */
-export function followsCollection(id) {
-  return collection(id, []);
+/**
+ * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2,
+ * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar
+ * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag.
+ *
+ * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog)
+ * op deze:
+ *   - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward
+ *   - ap_pending_follows: een ward op deze instance
+ * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan.
+ */
+export function followsCollection(id, slug, me) {
+  const items = follows.listReviewsByDirection(slug, 'incoming')
+    .map((r) => follows.reviewQueueItem(r, me));
+  for (const w of relations.listWards(slug)) {
+    const wardSlug = slugOf(w.other_uri);
+    if (!wardSlug) continue;
+    for (const p of follows.listForWard(wardSlug)) {
+      items.push({
+        id: p.id, type: 'Follow', actor: p.follower_uri, object: w.other_uri,
+        'shaer:direction': 'incoming', 'shaer:ward': w.other_uri,
+        'shaer:follower': p.follower_uri, 'shaer:followerHandle': p.follower_handle || undefined,
+        'shaer:quorum': p.quorum || 'any', published: p.created_at,
+      });
+    }
+  }
+  return collection(id, items);
 }
 
-/** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */
+/** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */
+function slugOf(uri) {
+  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
+  if (!base || !String(uri || '').startsWith(`${base}/ap/users/`)) return null;
+  return decodeURIComponent(String(uri).slice(`${base}/ap/users/`.length).split(/[/?#]/)[0]) || null;
+}
+
+/**
+ * §5.3 uitgaand. Twee lezers, een wachtrij, en dat kan omdat §1 een ward en een
+ * guardian wederzijds uitsluit: je bent het een of het ander.
+ *
+ *   ALS WARD      wat IK wil volgen en waar mijn guardians nog over moeten
+ *   ALS GUARDIAN  wat mijn WARDS willen volgen en waar IK over moet (shaer-jdb)
+ *
+ * Dat tweede ontbrak. De wachtrij serveerde alleen listForWard(slug), en voor
+ * een guardian is dat per definitie leeg -- dus het scherm "Your wards want to
+ * follow" kon nooit iets tonen.
+ */
 export function outgoingFollowsCollection(id, slug, me) {
-  return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me)));
+  const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me));
+  for (const r of follows.listReviewsByDirection(slug, 'outgoing')) {
+    items.push(follows.reviewQueueItem(r, me));
+  }
+  return collection(id, items);
 }
 
Index: src/views/partials/bottom-tab.ejs
===================================================================
--- src/views/partials/bottom-tab.ejs	(revision ea138c0c6a4bc5a508027368c44de1a8cdb6a326)
+++ src/views/partials/bottom-tab.ejs	(revision 1e172f36d16542632fe17c49029059a6f3b50bba)
@@ -302,17 +302,5 @@
 </style>
 
-<script>
-// Bottom-tab search button → opens #search-overlay. Using event delegation + looking
-// up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced
-// during htmx navigation, so a once-captured reference goes stale
-// (button did nothing afterwards). Guard against double-wiring.
-(function() {
-  if (window.__btSearchWired) return;
-  window.__btSearchWired = true;
-  document.addEventListener('click', function(e) {
-    if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return;
-    var o = document.getElementById('search-overlay');
-    if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); }
-  });
-})();
-</script>
+<%# De zoekknop zit nu in de chrome-module (assets/js/mod/chrome.js).
+    Inline script hier wordt door de CSP geweigerd zodra deze partial via htmx
+    binnenkomt, en dat is bij elke navigatie -- zie shaer-0i6. %>
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision ea138c0c6a4bc5a508027368c44de1a8cdb6a326)
+++ src/views/shell.ejs	(revision 1e172f36d16542632fe17c49029059a6f3b50bba)
@@ -339,5 +339,5 @@
 </head>
 
-<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
+<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-js="chrome<%= (typeof pageJs !== 'undefined' && pageJs) ? ' ' + pageJs : '' %>" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
 
 <% if (typeof isViewer !== 'undefined' && isViewer) { %>
@@ -449,4 +449,10 @@
     }
 
+    // Welke modules deze pagina wil (shaer-bqr). De bootstrap hieronder leest
+    // dit zodra deze handler klaar is.
+    if (typeof d.js === 'string' && /^[a-z0-9 -]*$/.test(d.js)) {
+      document.body.setAttribute('data-js', d.js);
+    }
+
     var next = d.bodyClass;
     if (!next) return;
@@ -467,4 +473,41 @@
   //  de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere
   //  htmx:historyRestore-handler is vervallen nu htmx-history uitstaat.)
+})();
+</script>
+
+<!-- Modules laden (shaer-bqr). Inline script in gewisselde inhoud wordt door de
+     CSP geweigerd: de nonce rouleert per verzoek, dus een script dat via htmx
+     binnenkomt draagt er een die dit document niet kent (shaer-0i6). Alles wat
+     bij een pagina hoort komt daarom uit een module, en die wordt HIER geladen —
+     vanuit de shell, die alleen bij een volledige laadbeurt binnenkomt en dus
+     wél de goede nonce heeft.
+
+     Een dynamische import vanuit een vertrouwd (genonced) script is precies waar
+     'strict-dynamic' voor bedoeld is, dus de module zelf heeft geen nonce nodig. -->
+<script>
+(function () {
+  if (window.__modBoot) return;
+  window.__modBoot = true;
+
+  var loaded = {};
+  function load() {
+    var names = (document.body.getAttribute('data-js') || '').trim().split(/\s+/);
+    names.forEach(function (name) {
+      // Streng: deze waarde komt uit een template en wordt een PAD. Alleen
+      // kleine letters, cijfers en streepjes; nooit een punt of een schuine
+      // streep.
+      if (!name || loaded[name] || !/^[a-z0-9-]+$/.test(name)) return;
+      loaded[name] = 1;
+      import('/assets/js/mod/' + name + '.js').catch(function (e) {
+        console.warn('[mod] ' + name + ' laadde niet:', e && e.message);
+      });
+    });
+  }
+  load();
+  // Bij een htmx-navigatie wisselt de INHOUD, niet de body. De nav-trigger
+  // hieronder zet data-js opnieuw; daarna halen we op wat er nieuw bij staat.
+  // Een module die er al is wordt niet opnieuw geimporteerd -- vandaar dat elke
+  // module gedelegeerd moet werken en tegen een tweede aanroep moet kunnen.
+  document.body.addEventListener('pcmsNav', load);
 })();
 </script>
