Index: src/assets/js/mod/admin-site-edit.js
===================================================================
--- src/assets/js/mod/admin-site-edit.js	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/assets/js/mod/admin-site-edit.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -42,22 +42,5 @@
   if (sh) sh.textContent = location.host + (sh.dataset.prefix || '/');
 })();
-(function() {
-  // Profile-links repeater: add row from <template>, remove on click.
-  var rows = document.getElementById('profile-links-rows');
-  var tpl  = document.getElementById('profile-link-template');
-  var add  = document.getElementById('profile-link-add');
-  if (!rows || !tpl || !add) return;
-
-  add.addEventListener('click', function() {
-    var clone = tpl.content.cloneNode(true);
-    rows.appendChild(clone);
-  });
-  rows.addEventListener('click', function(e) {
-    if (e.target && e.target.classList.contains('pl-remove')) {
-      var row = e.target.closest('.profile-link-row');
-      if (row) row.remove();
-    }
-  });
-})();
+// De profiellinks staan sinds 30-9 bij je profiel: zie mod/profile-links.js.
 
 // P63 — Profile photo picker: upload via /admin/sites/upload-photo,
Index: src/assets/js/mod/profile-links.js
===================================================================
--- src/assets/js/mod/profile-links.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
+++ src/assets/js/mod/profile-links.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -0,0 +1,28 @@
+// De profiellinks: een regel toevoegen uit het <template>, een regel weghalen.
+//
+// Stond in admin-site-edit.js, toen de links op de Appearance-pagina stonden.
+// Sinds 30-9 bewerk je ze bij je profiel (/account), naast je naam, bio en foto.
+//
+// De bootstrap in shell.ejs roept init() aan bij elke paginawissel waarop deze
+// module actief is; de vlag op het element voorkomt dat een tweede aanroep de
+// knoppen dubbel bedraadt.
+export function init() {
+  const rows = document.getElementById('profile-links-rows');
+  const tpl = document.getElementById('profile-link-template');
+  const add = document.getElementById('profile-link-add');
+  if (!rows || !tpl || !add || rows.__wired) return;
+  rows.__wired = true;
+  add.addEventListener('click', () => {
+    rows.appendChild(tpl.content.cloneNode(true));
+    const field = rows.lastElementChild && rows.lastElementChild.querySelector('input');
+    if (field) field.focus();
+  });
+  rows.addEventListener('click', (e) => {
+    const btn = e.target && e.target.closest && e.target.closest('.pl-remove');
+    if (!btn) return;
+    const row = btn.closest('.profile-link-row');
+    if (row) row.remove();
+  });
+}
+
+export default { init };
Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/middleware/render.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -213,4 +213,7 @@
     audioTracks: data.audioTracks || res.locals.audioTracks || [],
     siteUrlBase: res.locals.siteUrlBase || '',
+    // De wardmodus (middleware/ward-profile.js): een bezoeker van een ward-site ziet
+    // alleen het profiel, en de chrome biedt dan niets aan wat toch dicht is.
+    wardProfile: !!res.locals.wardProfile,
     footerNewsletter: getSetting('footer_newsletter') === '1', // newsletter sign-up in footer (premium)
     agendaEnabled: getSetting('agenda_enabled') === '1', // show agenda/events in the pill (premium, opt-in)
Index: src/middleware/ward-profile.js
===================================================================
--- src/middleware/ward-profile.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
+++ src/middleware/ward-profile.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -0,0 +1,108 @@
+/**
+ * De wardmodus: een besloten Klonkt waarvan alleen het profiel openbaar is
+ * (Robins ontwerp, 30-9).
+ *
+ * AAN ZOLANG DE SITE GUARDIANS HEEFT, en dat wordt niet opgeslagen maar
+ * afgeleid. listGuardians telt alleen GEACCEPTEERDE guardianships: de modus gaat
+ * aan zodra een koppeling rond is (niet bij een voorstel), en uit zodra de set
+ * leeg is -- en de set leegmaken is emancipatie (FEP-633c 3.4). Dat is precies
+ * waar het ontwerp de terugweg naar een gewone Klonkt legt. Een schakelaar zou
+ * een tweede waarheid naast de guardianship zijn, die kan vergeten worden of
+ * uit de pas kan lopen.
+ *
+ * WAT EEN BEZOEKER ZIET: de voorpagina wordt het profiel (naam, foto, bio,
+ * links), en verder niets. Elke andere pagina stuurt terug naar dat profiel;
+ * feeds, zoeken en alles wat geen pagina is geeft 404. STANDAARD DICHT: wat hier
+ * niet op de lijst staat komt er niet langs, zodat een nieuwe route niet vanzelf
+ * een kind openzet.
+ *
+ * WAT GEWOON DOORGAAT, en waarom:
+ *   - de federatie (ActivityPub, WebFinger, OAuth voor de Shaer-app) en /media,
+ *     /assets, /og: die hangen in server.js VOOR deze poort. Het kind post en
+ *     volgt via Shaer, en de volgpoort van de guardians regelt wie er meeleest;
+ *   - inloggen, het account, beheer en het guardianpaneel: die hebben hun eigen
+ *     bewaking, en de ward moet erbij kunnen;
+ *   - /audio/stream/: bewaakt elk bestand zelf, en een track die de ward op de
+ *     fediverse opende moet daar speelbaar blijven.
+ *
+ * WIE ALLES ZIET: de ward zelf (eigenaar), en wie de site mag beheren of
+ * bekijken (god, kijker, sitebeheer) -- dezelfde grens als elders.
+ */
+
+import * as Guardianship from '../services/guardianship/index.js';
+import PermissionsService from '../services/PermissionsService.js';
+import { parseProfileLinks } from '../services/PlatformIcons.js';
+import { renderPage } from './render.js';
+
+// Wat een bezoeker in de wardmodus WEL bereikt. Een map telt met alles eronder.
+const OPEN_DIRS = ['/auth', '/account', '/admin', '/guardian', '/audio/stream', '/lang',
+  // De federatie hangt in server.js al VOOR deze poort en komt hier dus nooit
+  // langs. Ze staan er toch, voor als iemand de volgorde ooit omgooit: dan zou
+  // een ward stil van de fediverse verdwijnen, en precies dat mag een
+  // opruimactie niet kunnen veroorzaken.
+  '/ap', '/.well-known', '/nodeinfo', '/oauth'];
+const OPEN_FILES = new Set(['/manifest.webmanifest', '/favicon.svg', '/favicon.ico', '/sw.js', '/robots.txt']);
+
+function isOpenPath(p) {
+  if (OPEN_FILES.has(p)) return true;
+  return OPEN_DIRS.some((d) => p === d || p.startsWith(d + '/'));
+}
+
+/** Heeft deze site guardians? Dan is het een ward, en staat de modus aan. */
+export function isWardSite(site) {
+  if (!site || !site.slug) return false;
+  try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; }
+}
+
+/** Ziet deze gebruiker de hele site, ook in de wardmodus? */
+export function seesWholeSite(user, site) {
+  if (!user) return false;
+  if (user.role === 'god' || user.role === 'kijker') return true;
+  try { return !!PermissionsService.canAdminSite(user, site); } catch { return false; }
+}
+
+/** De fediverse-handle van de site: @slug@host. */
+function handleOf(req, site) {
+  let host = '';
+  try { host = new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { /* terugval hieronder */ }
+  if (!host) host = req.get('host') || '';
+  return host ? `@${site.slug}@${host}` : `@${site.slug}`;
+}
+
+function renderWardProfile(req, res) {
+  const site = res.locals.site;
+  return renderPage(req, res, 'pages/ward-profile', {
+    pageTitle: site.profile_name || site.title || site.slug,
+    bodyClass: 'on-ward-profile',
+    wardHandle: handleOf(req, site),
+    wardLinks: parseProfileLinks(site.profile_links),
+  });
+}
+
+export function wardProfileGate(req, res, next) {
+  const site = res.locals.site;
+  res.locals.wardProfile = false;
+  if (!isWardSite(site)) return next();
+  if (seesWholeSite(req.session && req.session.user, site)) return next();
+
+  // Vanaf hier: een bezoeker van een ward-site. De views lezen deze vlag om
+  // niets aan te bieden wat toch dicht is (de zoekknop bijvoorbeeld).
+  res.locals.wardProfile = true;
+  const p = req.path;
+  if (isOpenPath(p)) return next();
+
+  const isRead = req.method === 'GET' || req.method === 'HEAD';
+  if (isRead && p === '/') return renderWardProfile(req, res);
+
+  const base = res.locals.siteUrlBase || '';
+  if (isRead && req.get('hx-request') === 'true') {
+    // Een htmx-navigatie zou het profiel als brokstuk in de pagina zetten.
+    // HX-Redirect laat de browser echt naar het profiel gaan.
+    res.set('HX-Redirect', base + '/');
+    return res.status(204).end();
+  }
+  if (isRead && req.accepts(['html', 'json']) === 'html') return res.redirect(302, base + '/');
+  return res.status(404).end();
+}
+
+export default { wardProfileGate, isWardSite, seesWholeSite };
Index: src/routes/account.js
===================================================================
--- src/routes/account.js	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/routes/account.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -14,4 +14,5 @@
 
 import express from 'express';
+import { listPlatforms, buildProfileLinks, parseProfileLinks } from '../services/PlatformIcons.js';
 import path from 'path';
 import fs from 'fs';
@@ -69,4 +70,9 @@
     pageTitle: 'Account',
     bodyClass: 'on-special',
+    // De profiellinks staan sinds 30-9 hier, bij je profiel, en niet meer op de
+    // Appearance-pagina (Robins verzoek).
+    pageJs: 'profile-links',
+    platforms: listPlatforms(),
+    profileLinks: editableSite ? parseProfileLinks(editableSite.profile_links) : [],
     account,
     hasPassword,
@@ -106,5 +112,7 @@
 function ownedSite(user) {
   if (!user) return null;
-  let site = db.prepare('SELECT id, title, tagline, slug, owner_id, profile_photo FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id);
+  // profile_links hoort erbij sinds de links hier staan (30-9). Zonder die kolom
+  // toonde de pagina nul links, en wiste opslaan ze dus.
+  let site = db.prepare('SELECT id, title, tagline, slug, owner_id, profile_photo, profile_links FROM sites WHERE owner_id = ? ORDER BY created_at LIMIT 1').get(user.id);
   if (!site && user.role === 'god') {
     site = getPrimarySite(); // primary/main site as fallback
@@ -128,4 +136,19 @@
 });
 
+// ==================== PROFIELLINKS ====================
+// Sinds 30-9 hier en niet meer op Appearance: ze horen bij je profiel, naast je
+// naam, bio en foto. Dezelfde rechten als de site-naam hierboven: de eigenaar
+// van de site, of god.
+router.post('/links', requireAuth, (req, res) => {
+  const site = ownedSite(req.session.user);
+  if (!site) return res.redirect('/account?error=' + encodeURIComponent('Geen site om te bewerken.'));
+  if (site.owner_id !== req.session.user.id && req.session.user.role !== 'god') {
+    return res.redirect('/account?error=' + encodeURIComponent('Geen rechten om deze site te bewerken.'));
+  }
+  db.prepare('UPDATE sites SET profile_links = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
+    .run(buildProfileLinks(req.body), site.id);
+  res.redirect('/account?success=' + encodeURIComponent('Links bijgewerkt') + '#links');
+});
+
 // ==================== UPDATE BIO ====================
 const RESERVED_USERNAMES = new Set(['admin', 'account', 'auth', 'login', 'register', 'logout', 'user', 'users', 'api', 'fediverse', 'posts', 'media', 'audio', 'assets', 'cirkel', 'authorize_interaction']);
Index: src/routes/admin-sites.js
===================================================================
--- src/routes/admin-sites.js	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/routes/admin-sites.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -55,22 +55,4 @@
 });
 
-/** Coerce req.body fields into the JSON profile_links array. */
-function buildProfileLinks(body) {
-  const platforms = body.profile_link_platform || [];
-  const urls = body.profile_link_url || [];
-  const arr = [];
-  const platformsArr = Array.isArray(platforms) ? platforms : [platforms];
-  const urlsArr      = Array.isArray(urls)      ? urls      : [urls];
-  for (let i = 0; i < platformsArr.length; i++) {
-    const p = (platformsArr[i] || '').toString().trim();
-    const u = (urlsArr[i] || '').toString().trim();
-    if (!p || !u) continue;
-    if (!PLATFORMS[p]) continue;
-    if (!/^https?:\/\//i.test(u) && p !== 'email') continue;
-    if (p === 'email' && !/^mailto:|^[^\s@]+@[^\s@]+$/i.test(u)) continue;
-    arr.push({ platform: p, url: u });
-  }
-  return arr.length ? JSON.stringify(arr) : null;
-}
 
 /**
@@ -211,5 +193,4 @@
     accents: ThemeService.listAccents(),
     platforms: listPlatforms(),
-    parsedLinks: [],
     apAliases: '',
     error: null,
@@ -275,8 +256,4 @@
   if (!site) return res.redirect('/admin/sites?error=Not+found');
 
-  let parsedLinks = [];
-  if (site.profile_links) {
-    try { parsedLinks = JSON.parse(site.profile_links) || []; } catch {}
-  }
 
   let apAliases = '';
@@ -293,5 +270,4 @@
     accents: ThemeService.listAccents(),
     platforms: listPlatforms(),
-    parsedLinks,
     apAliases,
     success: req.query.success || null,
@@ -333,5 +309,7 @@
   const feedAlt = ['timeline', 'auto'].includes(f.feed_alt_view) ? f.feed_alt_view : 'reader';
   const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : feedAlt;
-  const profileLinksJson = buildProfileLinks(f);
+  // De profiellinks staan sinds 30-9 bij je profiel (/account). Deze pagina
+  // schrijft ze dus NIET meer: met de velden weg zou elke keer opslaan hier ze
+  // wissen. De kolom blijft van /account/links.
 
   // FEP-7628 aliases — validated/resolved before anything is written.
@@ -366,5 +344,4 @@
       palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
       profile_enabled = ?,
-      profile_links = ?,
       ap_aliases = ?,
       is_public = ?, robots_index = ?, require_login_to_comment = ?,
@@ -386,5 +363,4 @@
     f.profile_photo || null,
     f.profile_enabled ? 1 : 0,
-    profileLinksJson,
     apAliasesJson,
     f.is_public ? 1 : 0,
Index: src/server.js
===================================================================
--- src/server.js	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/server.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -22,4 +22,5 @@
 import { getThumbnail, getRemoteThumbnail, verifyImg, THUMB_SIZES } from './services/ThumbnailService.js';
 import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js';
+import { wardProfileGate } from './middleware/ward-profile.js';
 import { isViewer } from './middleware/auth.js';
 import { renderPage } from './middleware/render.js';
@@ -396,4 +397,8 @@
 });
 
+// De wardmodus (30-9): heeft deze Klonkt guardians, dan ziet een bezoeker alleen
+// het profiel. Hier en niet eerder: de federatie, OAuth, /media en /assets hangen
+// hierboven en moeten gewoon blijven werken. Zie middleware/ward-profile.js.
+app.use(wardProfileGate);
 app.use('/auth', authRoutes);
 app.use('/account', accountRoutes);
Index: src/services/PlatformIcons.js
===================================================================
--- src/services/PlatformIcons.js	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/services/PlatformIcons.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -127,3 +127,35 @@
 }
 
-export default { PLATFORMS, PLATFORM_ORDER, listPlatforms, CONTACT_PLATFORMS, linkHref };
+/**
+ * De profiellinks uit een formulier (profile_link_platform[] + profile_link_url[])
+ * als de JSON die in sites.profile_links staat, of null als er niets over is.
+ *
+ * Stond in routes/admin-sites.js, toen de links op de Appearance-pagina stonden.
+ * Sinds 30-9 bewerk je ze bij je profiel (/account), en een tweede kopie van
+ * deze keuring loopt gegarandeerd uit de pas -- dus hij staat hier.
+ */
+export function buildProfileLinks(body) {
+  const platforms = (body && body.profile_link_platform) || [];
+  const urls = (body && body.profile_link_url) || [];
+  const arr = [];
+  const platformsArr = Array.isArray(platforms) ? platforms : [platforms];
+  const urlsArr      = Array.isArray(urls)      ? urls      : [urls];
+  for (let i = 0; i < platformsArr.length; i++) {
+    const p = (platformsArr[i] || '').toString().trim();
+    const u = (urlsArr[i] || '').toString().trim();
+    if (!p || !u) continue;
+    if (!PLATFORMS[p]) continue;
+    if (!/^https?:\/\//i.test(u) && p !== 'email') continue;
+    if (p === 'email' && !/^mailto:|^[^\s@]+@[^\s@]+$/i.test(u)) continue;
+    arr.push({ platform: p, url: u });
+  }
+  return arr.length ? JSON.stringify(arr) : null;
+}
+
+/** sites.profile_links terug als lijst; een kapotte waarde is een lege lijst. */
+export function parseProfileLinks(json) {
+  if (!json) return [];
+  try { const a = JSON.parse(json); return Array.isArray(a) ? a : []; } catch { return []; }
+}
+
+export default { PLATFORMS, PLATFORM_ORDER, listPlatforms, CONTACT_PLATFORMS, linkHref, buildProfileLinks, parseProfileLinks };
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/services/i18n.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -843,5 +843,5 @@
     'acct.tagline': 'Tagline',
     'acct.tagline_hint': '— korte oneliner (optioneel)',
-    'acct.site_save': 'Site opslaan',
+    'ward.followers_only': 'Berichten van {name} zijn alleen te zien voor volgers.', 'asite.links_moved': 'Je profiellinks staan nu bij je profiel.', 'asite.links_moved_link': 'Naar je profiel', 'acct.links_save': 'Links opslaan', 'acct.link_platform': 'Platform', 'acct.link_url': 'Adres', 'acct.link_remove': 'Link weghalen', 'acct.site_save': 'Site opslaan',
     'acct.password_change': 'Wachtwoord wijzigen',
     'acct.password_current': 'Huidig wachtwoord',
@@ -1852,5 +1852,5 @@
     'acct.tagline': 'Tagline',
     'acct.tagline_hint': '— short one-liner (optional)',
-    'acct.site_save': 'Save site',
+    'ward.followers_only': 'Posts by {name} are only visible to followers.', 'asite.links_moved': 'Your profile links now live with your profile.', 'asite.links_moved_link': 'Go to your profile', 'acct.links_save': 'Save links', 'acct.link_platform': 'Platform', 'acct.link_url': 'Address', 'acct.link_remove': 'Remove link', 'acct.site_save': 'Save site',
     'acct.password_change': 'Change password',
     'acct.password_current': 'Current password',
@@ -2861,5 +2861,5 @@
     'acct.tagline': 'Slogan',
     'acct.tagline_hint': '— kurzer Einzeiler (optional)',
-    'acct.site_save': 'Seite speichern',
+    'ward.followers_only': 'Beiträge von {name} sind nur für Follower sichtbar.', 'asite.links_moved': 'Deine Profil-Links findest du jetzt bei deinem Profil.', 'asite.links_moved_link': 'Zum Profil', 'acct.links_save': 'Links speichern', 'acct.link_platform': 'Plattform', 'acct.link_url': 'Adresse', 'acct.link_remove': 'Link entfernen', 'acct.site_save': 'Seite speichern',
     'acct.password_change': 'Passwort ändern',
     'acct.password_current': 'Aktuelles Passwort',
Index: src/views/pages/account.ejs
===================================================================
--- src/views/pages/account.ejs	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/views/pages/account.ejs	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -113,4 +113,54 @@
     </form>
   </section>
+  <% } %>
+
+  <%# ── PROFIELLINKS ───────────────────────────────────────────
+      Sinds 30-9 hier, bij je profiel, en niet meer op de Appearance-pagina: ze
+      horen bij je naam, bio en foto. In de wardmodus is dat profiel het enige
+      dat een bezoeker van de site ziet. %>
+  <% if (typeof editableSite !== 'undefined' && editableSite && canMutate) { %>
+  <section class="ax-card" id="links">
+    <div class="ax-card-title"><%= t('asite.links') %></div>
+    <p class="form-hint"><%= t('asite.links_hint') %></p>
+    <form action="/account/links" method="post" class="ax-form">
+      <div id="profile-links-rows">
+        <% (profileLinks || []).forEach(function (l) { %>
+          <div class="profile-link-row">
+            <select name="profile_link_platform[]" aria-label="<%= t('acct.link_platform') %>">
+              <% platforms.forEach(function (p) { %>
+                <option value="<%= p.key %>" <%= l.platform === p.key ? 'selected' : '' %>><%= p.label %></option>
+              <% }); %>
+            </select>
+            <input type="text" name="profile_link_url[]" value="<%= l.url %>" placeholder="https://…" required aria-label="<%= t('acct.link_url') %>">
+            <button type="button" class="ax-btn pl-remove" aria-label="<%= t('acct.link_remove') %>">×</button>
+          </div>
+        <% }); %>
+      </div>
+      <template id="profile-link-template">
+        <div class="profile-link-row">
+          <select name="profile_link_platform[]" aria-label="<%= t('acct.link_platform') %>">
+            <% platforms.forEach(function (p) { %>
+              <option value="<%= p.key %>"><%= p.label %></option>
+            <% }); %>
+          </select>
+          <input type="text" name="profile_link_url[]" placeholder="https://…" required aria-label="<%= t('acct.link_url') %>">
+          <button type="button" class="ax-btn pl-remove" aria-label="<%= t('acct.link_remove') %>">×</button>
+        </div>
+      </template>
+      <div class="ax-form-actions">
+        <button type="button" id="profile-link-add" class="ax-btn"><%= t('asite.link_add') %></button>
+        <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.links_save') %></button>
+      </div>
+    </form>
+  </section>
+  <style>
+    .profile-link-row { display: grid; grid-template-columns: 1fr auto; gap: .5rem; margin-bottom: .5rem; }
+    .profile-link-row select { padding: .45rem .6rem; grid-column: 1 / -1; }
+    .profile-link-row input { min-width: 0; }
+    @media (min-width: 520px) {
+      .profile-link-row { grid-template-columns: 160px 1fr auto; }
+      .profile-link-row select { grid-column: auto; }
+    }
+  </style>
   <% } %>
 
Index: src/views/pages/admin-site-edit.ejs
===================================================================
--- src/views/pages/admin-site-edit.ejs	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/views/pages/admin-site-edit.ejs	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -150,36 +150,8 @@
     </fieldset>
 
-    <fieldset class="profile-links-fieldset">
-      <legend><%= t('asite.links') %></legend>
-      <p class="form-hint"><%= t('asite.links_hint') %></p>
-
-      <div id="profile-links-rows">
-        <% (parsedLinks || []).forEach(function(l) { %>
-          <div class="profile-link-row">
-            <select name="profile_link_platform[]">
-              <% platforms.forEach(function(p) { %>
-                <option value="<%= p.key %>" <%= l.platform === p.key ? 'selected' : '' %>><%= p.label %></option>
-              <% }); %>
-            </select>
-            <input type="text" name="profile_link_url[]" value="<%= l.url %>" placeholder="https://…" required>
-            <button type="button" class="btn btn-danger pl-remove">×</button>
-          </div>
-        <% }); %>
-      </div>
-
-      <template id="profile-link-template">
-        <div class="profile-link-row">
-          <select name="profile_link_platform[]">
-            <% platforms.forEach(function(p) { %>
-              <option value="<%= p.key %>"><%= p.label %></option>
-            <% }); %>
-          </select>
-          <input type="text" name="profile_link_url[]" placeholder="https://…" required>
-          <button type="button" class="btn btn-danger pl-remove">×</button>
-        </div>
-      </template>
-
-      <button type="button" id="profile-link-add" class="btn"><%= t('asite.link_add') %></button>
-    </fieldset>
+    <%# De profiellinks staan sinds 30-9 bij je profiel, naast je naam, bio en foto:
+        dat is waar ze bij horen, en in de wardmodus is dat profiel het enige dat
+        een bezoeker ziet. %>
+    <p class="form-hint profile-links-moved"><%= t('asite.links_moved') %> <a href="/account#links"><%= t('asite.links_moved_link') %></a></p>
 
     <%# Aliassen en verhuizen staan sinds 14-8 op /admin/migrate, bij elkaar met
@@ -477,13 +449,3 @@
    button below. Prevents horizontal overflow on phones (fixed 160px column +
    non-shrinkable input together ~447px > screen width). */
-.profile-link-row {
-  display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; margin-bottom: 0.5rem;
-}
-.profile-link-row select { padding: 0.45rem 0.6rem; grid-column: 1 / -1; }
-.profile-link-row input { min-width: 0; }
-@media (min-width: 520px) {
-  .profile-link-row { grid-template-columns: 160px 1fr auto; }
-  .profile-link-row select { grid-column: auto; }
-}
-.profile-links-fieldset details > label { margin-top: 0.4rem; }
 </style>
Index: src/views/pages/ward-profile.ejs
===================================================================
--- src/views/pages/ward-profile.ejs	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
+++ src/views/pages/ward-profile.ejs	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -0,0 +1,145 @@
+<%#
+  Het profiel van een ward, en het enige dat een bezoeker van de site ziet
+  (Robins ontwerp, 30-9; zie middleware/ward-profile.js).
+
+  Omdat dit ALLES is wat er openbaar staat, mag het er verzorgd uitzien: een
+  eigen pagina en geen voorpagina met een lege stroom eronder.
+
+  Dezelfde bronnen als de profielkop (partials/profile-header.ejs), zodat het
+  profiel op geen twee plekken iets anders kan zeggen: naam uit profile_name of
+  title, tekst uit profile_bio, description of tagline, links uit profile_links.
+
+  NERGENS "KIND" OF "WARD". Dat deze account guardians heeft staat al openbaar op
+  de fediverse (shaer:guardians, FEP-633c 2.1); deze pagina hoeft dat niet in
+  koeienletters te herhalen. Wat een bezoeker wel hoort te weten, is dat de
+  berichten er niet voor hem staan -- neutraal gezegd.
+
+  Verwacht: site, wardHandle, wardLinks, platforms_catalog, avatar, t.
+%>
+<%
+  const _name = site.profile_name || site.title || site.slug;
+  const _bio = site.profile_bio || site.description || site.tagline || '';
+  const _photo = site.profile_photo || '';
+  const _catalog = (typeof platforms_catalog !== 'undefined' && platforms_catalog) || {};
+  const _links = (wardLinks || []).filter(function (l) { return l && _catalog[l.platform]; });
+  const _href = function (p, u) {
+    u = String(u || '').trim();
+    if (p === 'email' && u.indexOf('mailto:') !== 0) return 'mailto:' + u;
+    if (p === 'phone' && !/^tel:/i.test(u)) return 'tel:' + u.replace(/[^\d+]/g, '');
+    return u;
+  };
+  const _showFollow = site.slug && (typeof apEnabled === 'undefined' || apEnabled);
+%>
+<div class="wp" aria-labelledby="wp-name">
+  <div class="wp-glow" aria-hidden="true"></div>
+  <article class="wp-card">
+    <div class="wp-photo">
+      <% if (_photo) { %>
+        <img src="<%= avatar(_photo, 320) %>" alt="" width="160" height="160">
+      <% } else { %>
+        <span class="wp-photo-initial" aria-hidden="true"><%= String(_name).charAt(0).toUpperCase() %></span>
+      <% } %>
+    </div>
+
+    <h1 class="wp-name" id="wp-name"><%= _name %></h1>
+    <p class="wp-handle"><span><%= wardHandle %></span></p>
+    <% if (_bio) { %><p class="wp-bio"><%= _bio %></p><% } %>
+
+    <% if (_links.length || _showFollow) { %>
+      <ul class="wp-links">
+        <% if (_showFollow) { %>
+          <li>
+            <%# Opent de volgdialoog uit de profielkop (chrome.js). Een volgverzoek
+                aan deze account gaat eerst langs de guardians (FEP-633c 5.3). %>
+            <button type="button" class="wp-link wp-follow profile-fedi-link" data-fedi-actor-slug="<%= site.slug %>">
+              <span class="wp-link-ico" aria-hidden="true">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
+              </span>
+              <span class="wp-link-label"><%= t('fedi.profile_follow') %></span>
+              <span class="wp-link-go" aria-hidden="true">→</span>
+            </button>
+          </li>
+        <% } %>
+        <% _links.forEach(function (l) { var meta = _catalog[l.platform]; var ext = (l.platform !== 'email' && l.platform !== 'phone'); %>
+          <li>
+            <a class="wp-link" href="<%= _href(l.platform, l.url) %>" <% if (ext) { %>target="_blank" rel="noopener noreferrer"<% } %> style="--brand: <%= meta.brand %>">
+              <span class="wp-link-ico" aria-hidden="true"><%- meta.svg %></span>
+              <span class="wp-link-label"><%= meta.label %></span>
+              <span class="wp-link-go" aria-hidden="true"><%= ext ? '↗' : '→' %></span>
+            </a>
+          </li>
+        <% }); %>
+      </ul>
+    <% } %>
+
+    <p class="wp-note">
+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
+      <span><%= t('ward.followers_only', { name: _name }) %></span>
+    </p>
+  </article>
+</div>
+
+<style>
+  /* ── Het profiel, en verder niets ───────────────────────────────────────── */
+  body.on-ward-profile { background: var(--paper); }
+  .wp { position: relative; min-height: calc(100svh - 9rem); display: grid; place-items: center;
+    padding: clamp(2rem, 6vw, 4rem) 1.25rem calc(3rem + env(safe-area-inset-bottom, 0px)); overflow: hidden; isolation: isolate; }
+  /* Een zachte gloed in de accentkleur van de site, achter de kaart. */
+  .wp-glow { position: absolute; inset: -20% -10% auto; height: 70%; z-index: -1; pointer-events: none;
+    background:
+      radial-gradient(40% 55% at 30% 40%, color-mix(in srgb, var(--accent) 28%, transparent), transparent 70%),
+      radial-gradient(35% 50% at 72% 30%, color-mix(in srgb, var(--accent) 16%, var(--ink) 4%), transparent 70%);
+    filter: blur(28px); opacity: .9; }
+  .wp-card { width: 100%; max-width: 30rem; text-align: center; display: flex; flex-direction: column; align-items: center; }
+
+  .wp-photo { position: relative; width: 9.5rem; height: 9.5rem; border-radius: 50%; padding: 5px;
+    background: conic-gradient(from 210deg, var(--accent), color-mix(in srgb, var(--accent) 35%, var(--paper)), var(--accent));
+    box-shadow: 0 18px 50px color-mix(in srgb, var(--accent) 30%, transparent); }
+  .wp-photo img, .wp-photo-initial { display: block; width: 100%; height: 100%; border-radius: 50%; object-fit: cover;
+    border: 4px solid var(--paper); box-sizing: border-box; background: var(--paper-2, var(--paper)); }
+  .wp-photo-initial { display: grid; place-items: center; font: 700 3.5rem/1 var(--font-display, var(--font-ui, inherit)); color: var(--accent); }
+
+  .wp-name { margin: 1.4rem 0 .35rem; font-family: var(--font-display, inherit); font-size: clamp(2rem, 7vw, 2.8rem);
+    line-height: 1.1; letter-spacing: -.01em; color: var(--ink); overflow-wrap: anywhere; }
+  .wp-handle { margin: 0; }
+  .wp-handle span { display: inline-block; max-width: 100%; padding: .3rem .8rem; border-radius: 999px;
+    background: color-mix(in srgb, var(--ink) 6%, transparent); color: var(--ink-soft, var(--ink)); font-size: .9rem;
+    user-select: all; -webkit-user-select: all; overflow-wrap: anywhere; }
+  .wp-bio { margin: 1.1rem 0 0; font-size: 1.05rem; line-height: 1.6; color: var(--ink); max-width: 26rem; white-space: pre-line; }
+
+  /* De links: brede knoppen, zoals een link-in-bio. */
+  .wp-links { list-style: none; margin: 1.8rem 0 0; padding: 0; width: 100%; display: flex; flex-direction: column; gap: .6rem; }
+  .wp-link { display: flex; align-items: center; gap: .85rem; width: 100%; min-height: 3.25rem; padding: .55rem .9rem .55rem .6rem;
+    box-sizing: border-box; border-radius: 16px; border: 1px solid var(--rule); background: color-mix(in srgb, var(--paper) 88%, var(--ink) 4%);
+    color: var(--ink); text-decoration: none; font: inherit; font-weight: 600; text-align: left; cursor: pointer;
+    transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; -webkit-tap-highlight-color: transparent; }
+  .wp-link:hover, .wp-link:focus-visible { transform: translateY(-1px); border-color: color-mix(in srgb, var(--brand, var(--accent)) 55%, var(--rule));
+    box-shadow: 0 8px 22px color-mix(in srgb, var(--brand, var(--accent)) 18%, transparent); }
+  .wp-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+  .wp-link-ico { flex: 0 0 2.2rem; width: 2.2rem; height: 2.2rem; border-radius: 12px; display: grid; place-items: center;
+    background: var(--brand, var(--accent)); color: #fff; }
+  .wp-link-ico svg { width: 1.2rem; height: 1.2rem; fill: currentColor; }
+  .wp-follow .wp-link-ico { background: var(--accent); }
+  .wp-follow .wp-link-ico svg { fill: none; }
+  .wp-link-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .wp-link-go { flex: 0 0 auto; color: var(--ink-soft, var(--ink)); opacity: .7; }
+
+  /* Loopt de zin over twee regels, dan hoort hij naast het slotje te beginnen en
+     niet gecentreerd ver ervan af -- vandaar links uitgelijnd en een ronding die
+     ook bij twee regels klopt. */
+  .wp-note { display: inline-flex; align-items: center; gap: .6rem; max-width: 24rem; margin: 2rem 0 0; padding: .6rem .95rem;
+    border-radius: 14px; text-align: left; font-size: .85rem; line-height: 1.45;
+    color: var(--ink-soft, var(--ink)); background: color-mix(in srgb, var(--ink) 5%, transparent); }
+  .wp-note svg { flex: 0 0 1rem; width: 1rem; height: 1rem; }
+
+  /* Rustig binnenkomen; niet voor wie minder beweging wil. */
+  @media (prefers-reduced-motion: no-preference) {
+    .wp-card > * { animation: wp-in .5s cubic-bezier(.2, .7, .2, 1) both; }
+    .wp-card > :nth-child(2) { animation-delay: .05s; }
+    .wp-card > :nth-child(3) { animation-delay: .1s; }
+    .wp-card > :nth-child(4) { animation-delay: .15s; }
+    .wp-card > :nth-child(5) { animation-delay: .2s; }
+    .wp-card > :nth-child(6) { animation-delay: .25s; }
+    @keyframes wp-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
+  }
+</style>
Index: src/views/partials/bottom-tab.ejs
===================================================================
--- src/views/partials/bottom-tab.ejs	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/views/partials/bottom-tab.ejs	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -48,5 +48,6 @@
   </a>
 
-  <!-- Zoek -->
+  <!-- Zoek (niet in de wardmodus: daar is zoeken dicht voor een bezoeker) -->
+  <% if (!(typeof wardProfile !== 'undefined' && wardProfile)) { %>
   <button type="button" class="bottom-tab-item<%= _active === 'search' ? ' is-active' : '' %>"
           id="bottom-tab-search-toggle" aria-label="<%= t('nav.search') %>">
@@ -57,4 +58,5 @@
     <span class="bottom-tab-label"><%= t('tab.search') %></span>
   </button>
+  <% } %>
 
   <!-- Plus (FAB-style, only when user can post) -->
Index: src/views/partials/chrome.ejs
===================================================================
--- src/views/partials/chrome.ejs	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/views/partials/chrome.ejs	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -34,5 +34,7 @@
   <% if (!_headerless) { %>
     <%- include('profile-header') %>
-    <% if (!_isAdmin && (typeof site !== 'undefined' && site)) { %><%- include('view-switcher') %><% } %>
+    <%# Niet in de wardmodus: een bezoeker ziet daar alleen het profiel, en er
+        is geen stroom om tussen Lezen en Grid te wisselen. %>
+    <% if (!_isAdmin && (typeof site !== 'undefined' && site) && !(typeof wardProfile !== 'undefined' && wardProfile)) { %><%- include('view-switcher') %><% } %>
   <% } %>
 <% } %>
Index: src/views/partials/profile-header.ejs
===================================================================
--- src/views/partials/profile-header.ejs	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/views/partials/profile-header.ejs	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -10,7 +10,11 @@
 // Show profile-header unless we're on an admin page or the site explicitly
 // disabled it via profile_enabled=0.
-const _showProfile = site
-  && (site.profile_enabled === undefined || site.profile_enabled !== 0)
-  && !(typeof bodyClass !== 'undefined' && bodyClass.indexOf('on-admin') >= 0);
+// In de wardmodus IS de pagina het profiel (pages/ward-profile.ejs). Dan valt de
+// kop hier weg, maar de volgdialoog hieronder blijft: de volgknop op die pagina
+// opent hem.
+const _wardPage = typeof wardProfile !== 'undefined' && wardProfile;
+const _showProfile = site && (_wardPage || (
+  (site.profile_enabled === undefined || site.profile_enabled !== 0)
+  && !(typeof bodyClass !== 'undefined' && bodyClass.indexOf('on-admin') >= 0)));
 
 // profile_name and profile_bio used to be optional overrides exposed in the
@@ -20,4 +24,7 @@
 const _displayName = (site && (site.profile_name || site.title)) || '';
 const _bioText     = (site && (site.profile_bio || site.description || site.tagline)) || '';
+// Volgen via de fediverse. Hier boven en niet in de kop: op de wardpagina valt
+// de kop weg, maar de volgdialoog onderaan hoort er dan nog te staan.
+const _showFollow = (typeof canManageFedi === 'undefined' || !canManageFedi) && site && site.slug && (typeof apEnabled === 'undefined' || apEnabled);
 
 // Parse profile_links JSON column once.
@@ -38,4 +45,5 @@
 
 <% if (_showProfile) { %>
+<% if (!_wardPage) { %>
 <aside class="profile-header" aria-label="Site profile">
   <div class="container profile-header-inner">
@@ -74,5 +82,4 @@
       <%# Follow-via-fediverse: a compact icon in the links row (keeps the header
           short), and hidden for the owner — you don't follow yourself. %>
-      <% var _showFollow = (typeof canManageFedi === 'undefined' || !canManageFedi) && site && site.slug && (typeof apEnabled === 'undefined' || apEnabled); %>
       <% if (_profileLinks.length || _showFollow) { %>
         <% const _platforms = (typeof platforms_catalog !== 'undefined' && platforms_catalog) || {}; %>
@@ -144,4 +151,5 @@
   </div>
 </div>
+<% } /* !_wardPage */ %>
 <% if (_showFollow) { %>
 <%# Follow-via-fediverse modal — replaces the native prompt() (which looked ugly). %>
Index: src/views/partials/topnav.ejs
===================================================================
--- src/views/partials/topnav.ejs	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/views/partials/topnav.ejs	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -16,5 +16,7 @@
 // Fediverse pages (on-special) get the same top-nav "back to site" treatment as admin.
 const _isSpecial   = typeof bodyClass !== 'undefined' && bodyClass.indexOf('on-special') >= 0;
-const _hasSearch   = !site || site.show_search === undefined || site.show_search;
+// In de wardmodus is zoeken dicht voor een bezoeker: geen knop naar een deur op slot.
+const _hasSearch   = (!site || site.show_search === undefined || site.show_search)
+                     && !(typeof wardProfile !== 'undefined' && wardProfile);
 const _canPost     = user && (typeof canMutate === 'undefined' || canMutate)
                      && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site);
@@ -47,5 +49,6 @@
     <nav class="top-nav top-nav-desktop" aria-label="Hoofdnavigatie">
 
-      <% if (!_isAdmin && !_isSpecial) { %>
+      <%# Niet in de wardmodus: daar is het archief dicht voor een bezoeker. %>
+      <% if (!_isAdmin && !_isSpecial && !(typeof wardProfile !== 'undefined' && wardProfile)) { %>
         <a class="nav-link" href="<%= _siteUrlBase %>/archive"
            hx-get="<%= _siteUrlBase %>/archive?partial=1" hx-target="#pcms-main"
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 31680c3360308110ee32435ada564ef21f612bce)
+++ src/views/shell.ejs	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -130,5 +130,5 @@
 // ontwikkeling een jaar gecachet, dus zonder bump bereikt een reparatie
 // iedereen behalve wie de fout al heeft.
-const MOD_V = 67;
+const MOD_V = 68;
 %><!DOCTYPE html>
 <html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>">
Index: test/ward-profile.test.js
===================================================================
--- test/ward-profile.test.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
+++ test/ward-profile.test.js	(revision 746d98a771aa1e4a4241958d88582d516570cf4b)
@@ -0,0 +1,193 @@
+// De wardmodus: een besloten Klonkt waarvan alleen het profiel openbaar is
+// (Robins ontwerp, 30-9; middleware/ward-profile.js).
+//
+// Wat hier bewaakt wordt, en waarom elk een eigen zaak is:
+//   1. AFGELEID VAN DE GUARDIANS. Aan zodra een guardianship geaccepteerd is,
+//      niet bij een voorstel; uit zodra de set leeg is (emancipatie). Geen
+//      schakelaar die ernaast kan gaan liggen.
+//   2. STANDAARD DICHT. Een bezoeker ziet het profiel en verder niets; een
+//      pagina gaat terug naar het profiel, al het andere is 404, en een POST
+//      bereikt zijn handler niet.
+//   3. WAT OPEN MOET BLIJVEN, blijft open: inloggen, de audiostream (die bewaakt
+//      elk bestand zelf), de bestanden van de pagina zelf.
+//   4. DE WARD EN HET BEHEER zien de hele site.
+//   5. DE PROFIELLINKS staan bij het account, en Appearance wist ze niet meer.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import os from 'os';
+import path from 'path';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+process.env.MEDIA_PATH = path.join(os.tmpdir(), 'klonkt-test-media');
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+{ const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
+const { resolveSite } = await import('../src/middleware/site.js');
+const { wardProfileGate } = await import('../src/middleware/ward-profile.js');
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('kid', 'kid', 'k@t', 'x', 'user');
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('fan', 'fan', 'f@t', 'x', 'user');
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('baas', 'baas', 'b@t', 'x', 'god');
+db.prepare(`INSERT INTO sites (id, slug, title, description, owner_id, is_primary, is_public, profile_links)
+            VALUES (?,?,?,?,?,1,1,?)`)
+  .run('s1', 'kid', 'Robin', 'Ik maak muziek met mijn gitaar', 'kid',
+    JSON.stringify([{ platform: 'instagram', url: 'https://instagram.com/robin' }]));
+
+const GUARDIAN = 'https://elders.test/u/oma';
+const koppel = (status) => db.prepare("INSERT OR REPLACE INTO ap_guardianships (slug, role, other_uri, status) VALUES ('kid','ward',?,?)").run(GUARDIAN, status);
+const ontkoppel = () => db.prepare("DELETE FROM ap_guardianships WHERE slug = 'kid'").run();
+
+// Een Klonkt in het klein: de echte siteresolver en de echte poort, en daarachter
+// routes die alleen zeggen dat ze bereikt zijn.
+let sessie = {};
+const bereikt = [];
+const express = (await import('express')).default;
+const app = express();
+app.use((req, res, next) => { req.session = sessie; next(); });
+app.use(resolveSite);
+app.use(wardProfileGate);
+const hier = (naam) => (req, res) => { bereikt.push(naam); res.send('ROUTE:' + naam); };
+app.get('/', hier('feed'));
+app.get('/auth/login', hier('login'));
+app.get('/audio/stream/:f', hier('stream'));
+app.get('/manifest.webmanifest', hier('manifest'));
+app.get('/search', hier('search'));
+app.get('/feed.xml', hier('rss'));
+app.post('/:slug/comment', hier('comment'));
+app.get('/:slug', hier('post'));
+const server = app.listen(0);
+server.unref();
+const base = `http://127.0.0.1:${server.address().port}`;
+test.after(() => server.close());
+
+const vraag = (pad, opts = {}) => fetch(base + pad, { redirect: 'manual', headers: { Accept: 'text/html', ...(opts.headers || {}) }, ...opts });
+const alsBezoeker = () => { sessie = {}; };
+const als = (id, role) => { sessie = { user: { id, username: id, role } }; };
+
+test('zonder guardians is het een gewone Klonkt', async () => {
+  ontkoppel(); alsBezoeker();
+  const r = await vraag('/');
+  assert.equal(await r.text(), 'ROUTE:feed');
+});
+
+test('een voorstel is nog geen koppeling', async () => {
+  ontkoppel(); koppel('offered'); alsBezoeker();
+  assert.equal(await (await vraag('/')).text(), 'ROUTE:feed', 'pas een GEACCEPTEERDE guardianship zet de site dicht');
+});
+
+test('gekoppeld ziet een bezoeker alleen het profiel', async () => {
+  ontkoppel(); koppel('accepted'); alsBezoeker();
+  const r = await vraag('/');
+  const html = await r.text();
+  assert.equal(r.status, 200);
+  assert.ok(!html.includes('ROUTE:feed'), 'de stroom komt er niet aan te pas');
+  assert.ok(html.includes('Robin'), 'de naam');
+  assert.ok(html.includes('Ik maak muziek met mijn gitaar'), 'de bio');
+  assert.ok(html.includes('https://instagram.com/robin'), 'de links');
+  assert.ok(html.includes('@kid@test.example'), 'de handle, om te volgen');
+});
+
+test('elke andere pagina gaat terug naar het profiel', async () => {
+  koppel('accepted'); alsBezoeker();
+  for (const pad of ['/geheim', '/search?q=gitaar']) {
+    const r = await vraag(pad);
+    assert.equal(r.status, 302, pad);
+    assert.equal(r.headers.get('location'), '/', pad);
+  }
+});
+
+test('wat geen pagina is, bestaat niet voor een bezoeker', async () => {
+  koppel('accepted'); alsBezoeker(); bereikt.length = 0;
+  const rss = await vraag('/feed.xml', { headers: { Accept: 'application/rss+xml' } });
+  assert.equal(rss.status, 404);
+  const post = await vraag('/geheim/comment', { method: 'POST' });
+  assert.equal(post.status, 404);
+  assert.deepEqual(bereikt, [], 'geen enkele handler bereikt, ook de reactie niet');
+});
+
+test('een htmx-navigatie gaat echt naar het profiel, niet als brokstuk', async () => {
+  koppel('accepted'); alsBezoeker();
+  const r = await vraag('/geheim', { headers: { 'HX-Request': 'true', Accept: '*/*' } });
+  assert.equal(r.status, 204);
+  assert.equal(r.headers.get('hx-redirect'), '/');
+});
+
+test('inloggen, de audiostream en de bestanden van de pagina blijven bereikbaar', async () => {
+  koppel('accepted'); alsBezoeker();
+  assert.equal(await (await vraag('/auth/login')).text(), 'ROUTE:login', 'de ward moet kunnen inloggen');
+  assert.equal(await (await vraag('/audio/stream/zwaluwen.mp3')).text(), 'ROUTE:stream',
+    'die bewaakt elk bestand zelf, en een geopende track moet speelbaar blijven op de fediverse');
+  assert.equal(await (await vraag('/manifest.webmanifest')).text(), 'ROUTE:manifest');
+});
+
+test('de ward zelf en het beheer zien de hele site', async () => {
+  koppel('accepted');
+  als('kid', 'user');
+  assert.equal(await (await vraag('/')).text(), 'ROUTE:feed', 'de eigenaar ziet zijn eigen Klonkt');
+  assert.equal(await (await vraag('/geheim')).text(), 'ROUTE:post');
+  als('baas', 'god');
+  assert.equal(await (await vraag('/geheim')).text(), 'ROUTE:post');
+});
+
+test('een andere ingelogde gebruiker is gewoon een bezoeker', async () => {
+  koppel('accepted'); als('fan', 'user');
+  const r = await vraag('/geheim');
+  assert.equal(r.status, 302, 'ingelogd zijn is niet hetzelfde als deze site mogen beheren');
+});
+
+test('emancipatie maakt er weer een gewone Klonkt van', async () => {
+  koppel('accepted'); alsBezoeker();
+  assert.equal((await vraag('/geheim')).status, 302);
+  ontkoppel();
+  assert.equal(await (await vraag('/geheim')).text(), 'ROUTE:post', 'geen guardians meer: alles weer open');
+});
+
+// ── De profiellinks: bij het account, en Appearance wist ze niet meer ────────
+const accountRoutes = (await import('../src/routes/account.js')).default;
+const sitesRoutes = (await import('../src/routes/admin-sites.js')).default;
+const app2 = express();
+app2.use(express.urlencoded({ extended: true }));
+app2.use((req, res, next) => { req.session = sessie; res.locals.site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get(); res.locals.siteUrlBase = ''; next(); });
+app2.use('/account', accountRoutes);
+app2.use('/admin/sites', sitesRoutes);
+const server2 = app2.listen(0);
+server2.unref();
+const base2 = `http://127.0.0.1:${server2.address().port}`;
+test.after(() => server2.close());
+const links = () => JSON.parse(db.prepare("SELECT profile_links FROM sites WHERE id = 's1'").get().profile_links || '[]');
+
+test('de accountpagina toont de links die er al zijn', async () => {
+  // Gevonden bij het bekijken, niet door een toets: ownedSite haalde
+  // profile_links niet op, dus de pagina toonde nul links -- en wie daarna
+  // opsloeg, wiste ze. Het opslaan was getoetst, het tonen niet.
+  als('kid', 'user');
+  const html = await (await fetch(`${base2}/account`)).text();
+  assert.ok(html.includes('value="https://instagram.com/robin"'), 'de bestaande link staat in het formulier');
+});
+
+test('de links bewerk je bij je account', async () => {
+  als('kid', 'user');
+  const body = new URLSearchParams();
+  body.append('profile_link_platform[]', 'instagram'); body.append('profile_link_url[]', 'https://instagram.com/robin');
+  body.append('profile_link_platform[]', 'youtube'); body.append('profile_link_url[]', 'https://youtube.com/@robin');
+  const r = await fetch(`${base2}/account/links`, { method: 'POST', body, redirect: 'manual' });
+  assert.equal(r.status, 302);
+  assert.deepEqual(links().map((l) => l.platform), ['instagram', 'youtube']);
+});
+
+test('opslaan in Appearance wist de links niet meer', async () => {
+  // Hier zat de val: de linkvelden staan niet meer op dat formulier, en zolang
+  // die pagina profile_links bleef schrijven, wiste elke keer opslaan ze.
+  als('baas', 'god');
+  const voor = links();
+  assert.ok(voor.length > 0);
+  const body = new URLSearchParams({ title: 'Robin', description: 'Nu ook met drums', palette: 'klonkt' });
+  await fetch(`${base2}/admin/sites/kid/save`, { method: 'POST', body, redirect: 'manual' });
+  // Eerst bewijzen dat de opslag echt gebeurde. Een verkeerd pad geeft een 404,
+  // en dan "wist" hij de links ook niet -- dan zegt deze toets niets.
+  assert.equal(db.prepare("SELECT description FROM sites WHERE id = 's1'").get().description, 'Nu ook met drums',
+    'Appearance heeft opgeslagen');
+  assert.deepEqual(links(), voor, 'en de links staan er nog precies zo');
+});
