Index: src/assets/css/guardian.css
===================================================================
--- src/assets/css/guardian.css	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/assets/css/guardian.css	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -88,2 +88,32 @@
 .g-card.feed .feed-body { margin-top: 6px; line-height: 1.4; }
 .g-card.feed .feed-body img { max-width: 100%; border-radius: 8px; }
+
+/* ── The body of a post ────────────────────────────────────────────────────
+   The 🛟 card shows a real post, so it is rendered by the same partial de
+   Krant and Berichten use (partials/note-body). These are that partial's
+   classes, in the PWA's own colours: the dashboard is standalone and does not
+   load the site's stylesheet. */
+.g-note .tl-content { line-height: 1.5; overflow-wrap: anywhere; }
+.g-note .tl-content p { margin: .35rem 0; }
+.g-note .tl-content p:first-child { margin-top: 0; }
+.g-note .tl-content p:last-child { margin-bottom: 0; }
+.g-note .tl-content a { color: var(--accent); }
+.g-note img.emoji { height: 1.25em; width: auto; margin: 0 .05em; vertical-align: -.2em;
+  display: inline-block; border-radius: 0; background: none; }
+
+/* Quote card and link preview: one card, two origins. */
+.g-note .tl-quote { margin: 8px 0 0; padding: 8px 10px; border-radius: 10px;
+  border: 1px solid var(--line); background: var(--card-2, rgba(128,128,128,.07)); }
+.g-note .tl-quote-head { display: flex; align-items: center; gap: 6px; margin: 0 0 4px; min-width: 0; }
+.g-note .tl-quote-avatar { flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; object-fit: cover; }
+.g-note .tl-quote-name { font-weight: 600; font-size: .82rem; white-space: nowrap; }
+.g-note .tl-quote-handle { color: var(--sub); font-size: .76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
+.g-note .tl-quote-body { color: var(--sub); font-size: .88rem; line-height: 1.45; max-height: 14em; overflow: hidden; }
+.g-note .tl-quote-media { display: block; margin: 6px 0 0; }
+
+/* The capture a 🛟 usually carries. */
+.g-note .tl-media { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 0; }
+.g-note .tl-media-img { display: block; border-radius: 10px; overflow: hidden; }
+.g-note .tl-media-img img { width: 100%; height: auto; display: block; background: #fff; }
+.g-note .tl-media-video, .g-note .tl-media-audio { width: 100%; margin: 8px 0 0; border-radius: 10px; display: block; }
+.g-note .tl-media-video { max-height: 320px; background: #000; }
Index: src/assets/js/guardian.js
===================================================================
--- src/assets/js/guardian.js	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/assets/js/guardian.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -43,9 +43,17 @@
       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)));
+      var who = el('span', 'who grow');
+      // name_html carries the custom emojis (FEP-9098) of the display name, the
+      // same way de Krant renders a byline. Falls back to the plain name.
+      if (h.name_html) who.innerHTML = h.name_html;
+      else who.textContent = h.actor_name || handleOf(h.actor_uri, h.actor_handle);
+      row.appendChild(who);
       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
+      var body = el('div', 'body g-note');
+      // body_html is the shared note-body partial, rendered server-side: the
+      // content with its emojis, the quote / link-preview card and the media.
+      // Falls back to the bare content for rows stored before that existed.
+      body.innerHTML = h.body_html || h.content || '';   // sanitized server-side on ingest
       card.appendChild(body);
       if (h.note_url) {
Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/config/database.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -622,4 +622,15 @@
   ensureColumn('ap_timeline', 'has_guardians', 'INTEGER');
   ensureColumn('ap_mentions', 'has_guardians', 'INTEGER');
+  // Berichten and de Krant render a post the same way, so a mention or a reply
+  // needs the same trimmings a timeline row already has: custom emojis, the
+  // media the note carried, and the quote / link-preview card.
+  ensureColumn('ap_mentions', 'emoji_json', 'TEXT');        // FEP-9098, in the content
+  ensureColumn('ap_mentions', 'actor_emoji_json', 'TEXT');  // FEP-9098, in the display name
+  ensureColumn('ap_mentions', 'media_json', 'TEXT');
+  ensureColumn('ap_mentions', 'quote_json', 'TEXT');        // FEP-044f quoted post
+  ensureColumn('ap_mentions', 'embed_json', 'TEXT');        // external link preview
+  ensureColumn('ap_interactions', 'media_json', 'TEXT');
+  ensureColumn('ap_interactions', 'quote_json', 'TEXT');
+  ensureColumn('ap_interactions', 'embed_json', 'TEXT');
   ensureColumn('ap_followers', 'name', 'TEXT');    // cached display name (shaer-aa3)
   ensureColumn('ap_followers', 'handle', 'TEXT');  // @user@host
Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/middleware/render.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -37,4 +37,35 @@
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 const VIEWS_DIR = path.join(__dirname, '..', 'views');
+
+/**
+ * Render a post body to an HTML string with the SAME partial de Krant uses.
+ *
+ * For surfaces that are not an EJS page: the Guardian PWA builds its cards in
+ * the browser, so it gets the finished HTML in its state blob instead of the
+ * raw columns. One renderer, so a post cannot drift into looking different
+ * depending on where you run into it.
+ */
+export function renderNoteBody(nb, lang) {
+  if (!nb || !nb.content) return '';
+  const _l = lang || 'nl';
+  // ejs.renderFile hands back a Promise even with async:false, and the callers
+  // here are plain synchronous route code. Compile the file ourselves instead;
+  // `filename` is what lets the partial's own relative includes resolve.
+  const file = path.join(VIEWS_DIR, 'partials', 'note-body.ejs');
+  try {
+    return ejs.render(fs.readFileSync(file, 'utf8'), {
+      nb,
+      t: (key, vars) => i18nT(_l, key, vars),
+      emojiHtml,
+      emojiName,
+      noteQuote: parseQuote,
+      thumb: (url, w) => (typeof url === 'string' && /^https?:\/\//i.test(url) ? imgProxyUrl(url, w || 480) : url),
+      avatar: (url, w) => (typeof url === 'string' && /^https?:\/\//i.test(url) ? imgProxyUrl(url, w || 128) : url),
+    }, { filename: file, async: false });
+  } catch (e) {
+    console.warn('[render] note body failed:', e.message);
+    return '';
+  }
+}
 
 // App version (from package.json) + short commit hash (from .klonkt-version, written by
Index: src/routes/guardian.js
===================================================================
--- src/routes/guardian.js	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/routes/guardian.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -19,5 +19,6 @@
 import * as Guardianship from '../services/guardianship/index.js';
 import { t as i18nT, resolveLang } from '../services/i18n.js';
-import { injectCspNonce } from '../middleware/render.js';
+import { injectCspNonce, renderNoteBody } from '../middleware/render.js';
+import { emojiName } from '../services/NoteRender.js';
 
 const router = express.Router();
@@ -51,7 +52,15 @@
   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
+    `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at,
+            emoji_json, actor_emoji_json, media_json, quote_json, embed_json
      FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
-  ).all(site.slug);
+  ).all(site.slug).map((h) => ({
+    ...h,
+    // The dashboard is built in the browser, so it gets the body finished: the
+    // same partial de Krant and Berichten use. A 🛟 often carries a screenshot
+    // and a link to the post it is about; both belong in the card.
+    body_html: renderNoteBody(h, L),
+    name_html: emojiName(h.actor_name || '', h.actor_emoji_json),
+  }));
   return {
     site: site.slug,
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/routes/posts.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -920,5 +920,5 @@
   const append = req.query.append === '1';
   const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
-  const page = site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : [];
+  const page = gateEmbeds(site, site ? ActivityPubService.getMessages(site.slug, FEED_PAGE + 1, offset) : []);
   const hasMore = page.length > FEED_PAGE;
   const items = page.slice(0, FEED_PAGE);
@@ -1084,4 +1084,20 @@
 }
 
+/**
+ * FEP-633c §5.3-style gated feature: may this account see previews of links
+ * that point OUTSIDE the fediverse? For a ward that is the guardians' call.
+ *
+ * Applied at SERVE time on every surface, the way the app's inbox read already
+ * does it (routes/activitypub.js): a card the client merely hides has still
+ * been delivered.
+ */
+function gateEmbeds(site, rows) {
+  if (!site || !rows.length) return rows;
+  let isWard = false;
+  try { isWard = Guardianship.listGuardians(site.slug).length > 0; } catch { /* no relations yet */ }
+  if (Guardianship.externalEmbedsAllowed(site.external_embeds, isWard)) return rows;
+  return rows.map((r) => (r && r.embed_json ? { ...r, embed_json: null } : r));
+}
+
 router.get('/news', requireSiteManager, (req, res) => {
   const site = res.locals.site;
@@ -1090,5 +1106,5 @@
   const cspOrigins = new Set();
   // Fetch one extra to know whether a "Load more" button belongs on this page.
-  const rows = site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : [];
+  const rows = gateEmbeds(site, site ? ActivityPubService.getTimeline(site.slug, FEED_PAGE + 1, offset) : []);
   const hasMore = rows.length > FEED_PAGE;
   const timeline = rows.slice(0, FEED_PAGE).map((p) => {
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/services/ActivityPubService.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -832,4 +832,17 @@
 }
 
+/**
+ * Does this note belong in the home timeline (de Krant)?
+ *
+ * Only if it is a POST. A direct note is addressed to named people, so it is a
+ * message: a plain DM, a ward's 🛟 help request (FEP-633c 5.2.1) or a
+ * guardian's wave. Those are stored as mentions instead and surface in
+ * Berichten and the Guardian PWA. A reply belongs to its thread, not the feed.
+ */
+export function belongsInTimeline(o) {
+  if (!o || !o.id || o.inReplyTo) return false;
+  return noteVisibility(o) !== 'direct';
+}
+
 function iStmts() {
   if (!_insI) {
@@ -1524,4 +1537,17 @@
       iStmts().ins.run('reply', tgt.post_id, o.id || '', actorUri, ai.name, ai.handle, ai.url, ai.icon, html, o.published || null, tgt.parent_uri, noteVisibility(o), extractEmojiTags(o.tag), emojiJsonOf(ai.emojis));
       console.log('[AP] reply', actorUri, '→', tgt.post_id);
+      // A reply is a post too: Berichten renders it the way de Krant renders a
+      // timeline row, so it needs the same media and the same quote/preview card.
+      {
+        const where = 'kind = ? AND post_id = ? AND actor_uri = ? AND object_uri = ?';
+        const key = ['reply', tgt.post_id, actorUri, o.id || ''];
+        const mj = mediaFromNote(o);
+        if (mj && mj !== '[]') { try { db.prepare(`UPDATE ap_interactions SET media_json = ? WHERE ${where}`).run(mj, ...key); } catch { /* ignore */ } }
+        resolveCard(o).then((c) => {
+          if (!c) return;
+          const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
+          try { db.prepare(`UPDATE ap_interactions SET ${col} = ? WHERE ${where}`).run(c.json, ...key); } catch { /* ignore */ }
+        }).catch(() => { /* best-effort */ });
+      }
       {
         // Private (followers/direct) replies push as a DM ping WITHOUT content
@@ -1541,5 +1567,5 @@
     }
     // Home timeline (client): a top-level post from an account we follow.
-    if (actorUri && !isLocalActor && !o.inReplyTo && o.id) {
+    if (actorUri && !isLocalActor && belongsInTimeline(o)) {
       let subs = []; try { subs = db.prepare('SELECT slug, auto_boost FROM ap_following WHERE actor_uri = ?').all(actorUri); } catch { /* table may not exist yet */ }
       if (subs.length) {
@@ -1610,7 +1636,17 @@
         for (const slug of slugs) {
           try {
-            const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
-              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0);
+            const r = db.prepare(`INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, wave, has_guardians, emoji_json, actor_emoji_json, media_json, created_at)
+                                  VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
+              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0, wave ? 1 : 0, hasG ? 1 : 0,
+                extractEmojiTags(o.tag), emojiJsonOf(ai.emojis), mediaFromNote(o));
             if (r.changes) {
+              // The quote / link-preview card resolves out of band (a remote
+              // fetch), exactly as it does for a timeline post, so the inbox
+              // answer is never blocked on it.
+              resolveCard(o).then((c) => {
+                if (!c) return;
+                const col = c.column === 'quote_json' ? 'quote_json' : 'embed_json';   // never a value from the wire
+                try { db.prepare(`UPDATE ap_mentions SET ${col} = ? WHERE slug = ? AND object_uri = ?`).run(c.json, slug, o.id); } catch { /* ignore */ }
+              }).catch(() => { /* best-effort */ });
               console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
               const vis = noteVisibility(o);
@@ -2849,5 +2885,5 @@
 // during a flux window, e.g. a fleet-wide update), and drops notes that are gone
 // (404/410). Bump SELFHEAL_VERSION only on a release that warrants a re-sync.
-const SELFHEAL_VERSION = 20; // v20: oEmbed provider registry, so platforms that hide their tags from a server still resolve
+const SELFHEAL_VERSION = 21; // v21: drop direct notes (🛟 help requests, waves) that were cached as timeline posts
 async function fetchNoteAP(url) {
   try {
@@ -2991,4 +3027,22 @@
   return JSON.stringify(snapshot);
 }
+
+/**
+ * The card under a post: a fediverse quote (FEP-044f) when the note has one,
+ * otherwise an external link preview. Both render as the SAME card, so only one
+ * of the two is ever stored. Returns {column, json} or null.
+ *
+ * Both halves reach out over the network, which is why every caller runs this
+ * out of band: an inbox answer must never wait on a third party.
+ */
+async function resolveCard(o) {
+  if (quoteHrefOf(o)) {
+    const qj = await resolveQuote(o);
+    return qj ? { column: 'quote_json', json: qj } : null;
+  }
+  const ej = await resolveExternalEmbed(o && o.content);
+  return ej ? { column: 'embed_json', json: ej } : null;
+}
+
 // A generic SSRF-safe AP GET (collections / pages).
 async function apGetJson(url) {
@@ -3158,4 +3212,16 @@
     try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('selfheal_version'); cur = r ? (parseInt(r.value, 10) || 0) : 0; } catch { return; }
     if (cur >= SELFHEAL_VERSION) return; // already healed for this version — skip on normal boots
+    // v21: direct notes used to land in the timeline as if they were posts, so a
+    // ward's 🛟 help request showed up in the guardian's Krant. The insert now
+    // refuses them; drop the ones already cached. Scoped to the two kinds we can
+    // still recognise afterwards (help request, wave) — a plain public mention
+    // from someone you follow IS a timeline post and must stay.
+    try {
+      const r = db.prepare(`DELETE FROM ap_timeline WHERE EXISTS (
+        SELECT 1 FROM ap_mentions m
+         WHERE m.object_uri = ap_timeline.id AND m.slug = ap_timeline.slug
+           AND (m.help_request = 1 OR m.wave = 1))`).run();
+      if (r.changes) console.log(`[AP] self-heal v21: ${r.changes} direct note(s) removed from the timeline`);
+    } catch { /* table may predate the columns */ }
     let rows = [];
     try { rows = db.prepare('SELECT id, slug, content, media_json, nsfw, cw, url, emoji_json, link_json, quote_json, author_uri, author_name, author_emoji_json, reblog_name, reblog_handle, reblog_emoji_json, embed_json FROM ap_timeline ORDER BY rowid DESC LIMIT 200').all(); } catch { /* no table */ }
@@ -3456,5 +3522,5 @@
     const rows = db.prepare(`
       SELECT i.kind, i.actor_name, i.actor_handle, i.actor_url, i.actor_icon, i.content, i.created_at, i.visibility,
-             i.emoji_json, i.actor_emoji_json,
+             i.emoji_json, i.actor_emoji_json, i.media_json, i.quote_json, i.embed_json,
              p.slug AS post_slug, p.title AS post_title
       FROM ap_interactions i LEFT JOIN posts p ON p.id = i.post_id
@@ -3466,4 +3532,5 @@
       content: stripLeadingMentions(r.content), post_slug: r.post_slug, post_title: r.post_title, created_at: r.created_at,
       emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json,   // FEP-9098 (messages render)
+      media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json,   // rendered like a Krant post
       // followers/direct = a private message to the owner (not on the public thread) → 🔒 in Messages
       visibility: r.visibility || 'public',
@@ -3488,6 +3555,10 @@
   } catch { /* ignore */ }
   try {
-    for (const r of db.prepare('SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, created_at FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?').all(slug, L)) {
-      out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at });
+    for (const r of db.prepare(`SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, wave, help_request, created_at,
+                                       emoji_json, actor_emoji_json, media_json, quote_json, embed_json
+                                FROM ap_mentions WHERE slug = ? ORDER BY created_at DESC LIMIT ?`).all(slug, L)) {
+      out.push({ type: 'mention', name: r.actor_name, handle: r.actor_handle, url: r.actor_url || r.actor_uri, icon: r.actor_icon, content: stripLeadingMentions(r.content), note_url: r.note_url || r.object_uri, wave: r.wave ? 1 : 0, help_request: r.help_request ? 1 : 0, actorUri: r.actor_uri, created_at: r.created_at,
+        // Same trimmings a Krant row has, so Berichten renders the post identically.
+        emoji_json: r.emoji_json, actor_emoji_json: r.actor_emoji_json, media_json: r.media_json, quote_json: r.quote_json, embed_json: r.embed_json });
     }
   } catch { /* ignore */ }
@@ -3716,5 +3787,5 @@
   getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
   linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
-  noteVisibility, isRejectedObject, rejectInteraction, interactionReportTarget,
+  noteVisibility, belongsInTimeline, isRejectedObject, rejectInteraction, interactionReportTarget,
   getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched,
 };
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/services/i18n.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -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.', '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.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
+    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'msg.guardians_label': 'Jouw guardians', 'msg.waved_at_you': 'zwaaide naar je', 'msg.help_request': 'vroeg om hulp', 'msg.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.embeds_on': 'Linkvoorbeelden: aan', 'guardian.embeds_off': 'Linkvoorbeelden: uit', 'guardian.embeds_propose': 'Linkvoorbeelden voorstellen', 'guardian.embeds_waiting': 'wacht op de andere guardians', 'guardian.release_confirm': '{who} loslaten?\n\nJe stopt dan als guardian. Je ziet hun berichten niet meer, je krijgt geen hulpverzoeken meer van ze, en je kunt volgverzoeken niet meer voor ze beoordelen.\n\nTerugkomen kan alleen met een nieuwe aanvraag die zij accepteren.', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_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.', '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.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
+    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'msg.guardians_label': 'Your guardians', 'msg.waved_at_you': 'waved at you', 'msg.help_request': 'asked for help', 'msg.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.embeds_on': 'Link previews: on', 'guardian.embeds_off': 'Link previews: off', 'guardian.embeds_propose': 'Propose link previews', 'guardian.embeds_waiting': 'waiting for the other guardians', 'guardian.release_confirm': 'Release {who}?\n\nYou stop being their guardian. You will no longer see their posts, no longer receive their calls for help, and no longer decide on follow requests for them.\n\nComing back means a fresh offer that they accept.', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_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.', '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.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
+    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'msg.guardians_label': 'Deine Guardians', 'msg.waved_at_you': 'hat dir zugewinkt', 'msg.help_request': 'hat um Hilfe gebeten', 'msg.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.embeds_on': 'Linkvorschauen: an', 'guardian.embeds_off': 'Linkvorschauen: aus', 'guardian.embeds_propose': 'Linkvorschauen vorschlagen', 'guardian.embeds_waiting': 'wartet auf die anderen Guardians', 'guardian.release_confirm': '{who} loslassen?\n\nDu bist dann nicht mehr Guardian. Du siehst ihre Beitraege nicht mehr, erhaeltst keine Hilferufe mehr von ihnen und entscheidest nicht mehr ueber Follow-Anfragen fuer sie.\n\nZurueck geht nur mit einem neuen Angebot, das sie annehmen.', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_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/news.ejs
===================================================================
--- src/views/pages/news.ejs	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/views/pages/news.ejs	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -124,8 +124,7 @@
   .tl-time { color: var(--ink-soft, #999); font-size: .78rem; flex: 0 0 auto; align-self: flex-start; white-space: nowrap; }
 
-  .tl-content { line-height: 1.55; overflow-wrap: anywhere; }
-  .tl-content p { margin: .4rem 0; } .tl-content p:first-child { margin-top: 0; } .tl-content p:last-child { margin-bottom: 0; }
-  .tl-content a { color: var(--accent, #06c); }
-  .tl-content img { max-width: 100%; height: auto; border-radius: 10px; background: #fff; }
+  /* .tl-content, .tl-quote* and .tl-media* now live in partials/shared-styles,
+     next to partials/note-body: the markup is shared with Berichten and the
+     Guardian PWA, so the styling has to be too. What stays here is Krant-only. */
   /* Long posts collapse to a max height with a fade + "read more" (added by JS only when it overflows). */
   .tl-content.tl-clamp { max-height: 20em; overflow: hidden;
@@ -137,32 +136,4 @@
   .tl-readmore:hover { text-decoration: underline; }
 
-  /* FEP-044f embedded quote card (mirror of the Shaer QuoteCard). */
-  .tl-quote { margin: .75rem 0 0; padding: .6rem .7rem; border-radius: 12px;
-    border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);
-    background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent); }
-  .tl-quote-head { display: flex; align-items: center; gap: .4rem; margin: 0 0 .35rem; min-width: 0; }
-  .tl-quote-avatar { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; object-fit: cover; background: #fff; }
-  .tl-quote-name { font-weight: 700; font-size: .85rem; color: var(--ink, inherit); white-space: nowrap; }
-  .tl-quote-handle { color: var(--ink-soft, #888); font-size: .78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
-  .tl-quote-body { line-height: 1.5; font-size: .92rem; color: var(--ink-soft, #555);
-    overflow-wrap: anywhere; max-height: 16em; overflow: hidden; }
-  .tl-quote-body p { margin: .3rem 0; } .tl-quote-body p:first-child { margin-top: 0; } .tl-quote-body p:last-child { margin-bottom: 0; }
-  .tl-quote-body a { color: var(--accent, #06c); }
-  .tl-quote-media { display: block; margin: .45rem 0 0; }
-  .tl-quote-media img { max-width: 100%; height: auto; border-radius: 8px; }
-
-  /* Always show the FULL image at its natural ratio — never cropped, no letterbox. */
-  .tl-media { display: flex; flex-direction: column; gap: .4rem; margin: .75rem 0 0; }
-  .tl-media-img { display: block; border-radius: 12px; overflow: hidden;
-    background-image: linear-gradient(135deg,
-      color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2, var(--paper))) 0%,
-      color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2, var(--paper))) 100%); }
-  .tl-media-img img { width: 100%; height: auto; display: block; background: #fff; transition: opacity .4s ease; }
-  .tl-media-img img.is-loading { opacity: 0; }
-  /* Reserve placeholder space while the (natural-aspect) feed image loads. */
-  .tl-media-img:has(img.is-loading) { min-height: 220px; }
-
-  .tl-media-video, .tl-media-audio { width: 100%; margin: .75rem 0 0; border-radius: 12px; display: block; }
-  .tl-media-video { max-height: 480px; background: #000; }
   .tl-poll { margin: .75rem 0 0; display: flex; flex-direction: column; gap: 8px; }
   .tl-poll-form { display: flex; flex-direction: column; gap: 8px; }
Index: src/views/partials/msg-item.ejs
===================================================================
--- src/views/partials/msg-item.ejs	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/views/partials/msg-item.ejs	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -55,4 +55,5 @@
               <% } else if (_t === 'boost') { %><%= t('notif.boosted') %>
               <% } else if (_t === 'report') { %><%= t('notif.reported') %>
+              <% } else if (_t === 'mention' && n.help_request) { %>🛟 <%= t('msg.help_request') %>
               <% } else if (_t === 'mention' && n.wave) { %>👋 <%= t('msg.waved_at_you') %>
               <% } else if (_t === 'mention') { %><%= t('notif.mentioned') %>
@@ -67,5 +68,5 @@
 
             <% if (_t === 'report' && n.content) { %><div class="msg-content"><%= n.content %></div>
-            <% } else if ((_t === 'reply' || _t === 'mention' || _t === 'sent') && n.content) { %><div class="msg-content"><%- emojiHtml(n.content, n.emoji_json) %></div>
+            <% } else if ((_t === 'reply' || _t === 'mention' || _t === 'sent') && n.content) { %><div class="msg-content msg-note"><%- include('../partials/note-body', { nb: n }) %></div>
               <% if (_t === 'mention' && n.wave && n.actorUri && canMutate) { %>
                 <form class="msg-quickreply" method="post" action="<%= (typeof siteUrlBase !== 'undefined' ? siteUrlBase : '') %>/messages/quick-reply" style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">
Index: src/views/partials/note-body.ejs
===================================================================
--- src/views/partials/note-body.ejs	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
+++ src/views/partials/note-body.ejs	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -0,0 +1,47 @@
+<%
+  /* The body of a post, rendered the same way wherever a post shows up: de
+     Krant, Berichten and the Guardian PWA. Takes `nb` with the column names an
+     ap_timeline row uses (content, emoji_json, media_json, quote_json,
+     embed_json, nsfw, cw); ap_mentions and ap_interactions carry the same names
+     so a mention or a reply can be handed straight to it.
+
+     Krant-only trimmings (the boost byline, polls, the audio player iframe, the
+     action bar) stay in tl-item: they are about the feed, not about the post. */
+  var _m = []; try { _m = JSON.parse(nb.media_json || '[]'); } catch (e) { _m = []; }
+  var _imgs = _m.filter(function (m) { return m && m.url && (!m.type || /^image\//.test(m.type)); });
+  var _vids = _m.filter(function (m) { return m && m.url && m.type && /^video\//.test(m.type); });
+  var _auds = _m.filter(function (m) { return m && m.url && m.type && /^audio\//.test(m.type); });
+  var _hasVisual = _imgs.length || _vids.length || _auds.length;
+  var _nsfwVisual = !!nb.nsfw && _hasVisual;
+  var _nsfwText = !!nb.nsfw && !_hasVisual;
+  // One card for both: a fediverse quote and an external link preview look
+  // identical; only where they came from differs. An embed carries a title and
+  // a thumbnail, never an iframe.
+  var _quote = noteQuote(nb.quote_json);
+  if (!_quote) {
+    var _emb = noteQuote(nb.embed_json);
+    // The title comes from a third-party oEmbed provider, so it is escaped
+    // here: the quote card renders `content` as HTML (fine for AP content,
+    // which we sanitise on the way in, but not for this).
+    if (_emb) _quote = { url: _emb.url, author: _emb.author || (_emb.provider ? { name: _emb.provider } : null),
+                         content: _emb.title ? ('<p>' + emojiName(_emb.title, null) + '</p>') : '', media: _emb.media || [] };
+  }
+  var _noAudio = !!nb.suppressAudio;   // the Klonkt player iframe already covers these tracks
+%>
+<% if (_nsfwText) { %>
+  <div class="tl-content nsfw-media"><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: nb.cw }) %></span><%- emojiHtml(nb.content, nb.emoji_json) %></div>
+<% } else { %>
+  <div class="tl-content"><%- emojiHtml(nb.content, nb.emoji_json) %></div>
+<% } %>
+<% if (_quote) { %><%- include('../partials/quote-card', { q: _quote }) %><% } %>
+<% if (_nsfwVisual) { %><div class="nsfw-media"><% } %>
+<% if (_imgs.length) { %>
+  <div class="tl-media">
+    <% _imgs.forEach(function (m) { %>
+      <a class="tl-media-img" href="<%= m.url %>" target="_blank" rel="noopener"><img src="<%= thumb(m.url, 1280) %>" alt="" loading="lazy" decoding="async"></a>
+    <% }); %>
+  </div>
+<% } %>
+<% _vids.forEach(function (m) { %><video class="tl-media-video" src="<%= m.url %>" poster="<%= thumb(m.url, 1280) %>" controls preload="metadata" playsinline></video><% }); %>
+<% if (!_noAudio) { _auds.forEach(function (m) { %><audio class="tl-media-audio" src="<%= m.url %>" controls preload="none"></audio><% }); } %>
+<% if (_nsfwVisual) { %><div class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: nb.cw }) %></div></div><% } %>
Index: src/views/partials/shared-styles.ejs
===================================================================
--- src/views/partials/shared-styles.ejs	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/views/partials/shared-styles.ejs	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -202,3 +202,50 @@
 .tl-author img.emoji, .tl-boost-by img.emoji, .tl-quote-name img.emoji, .msg-who img.emoji, .comment-author img.emoji {
   max-width: none; height: 1.3em; border-radius: 0; background: none; box-shadow: none; vertical-align: -0.2em; }
+
+/* ── The body of a post ────────────────────────────────────────────────────
+   Shared with partials/note-body.ejs, so a post looks the same in de Krant, in
+   Berichten and in the Guardian PWA. These used to live in the Krant's own
+   <style> block, which is why a post outside de Krant came out unstyled.
+   Krant-only behaviour (the clamp + "read more", the poll, the action bar)
+   stays in pages/news.ejs. */
+.tl-content { line-height: 1.55; overflow-wrap: anywhere; }
+.tl-content p { margin: .4rem 0; } .tl-content p:first-child { margin-top: 0; } .tl-content p:last-child { margin-bottom: 0; }
+.tl-content a { color: var(--accent, #06c); }
+.tl-content img { max-width: 100%; height: auto; border-radius: 10px; background: #fff; }
+
+/* FEP-044f embedded quote card (mirror of the Shaer QuoteCard). Also carries
+   an external link preview: same card, different origin. */
+.tl-quote { margin: .75rem 0 0; padding: .6rem .7rem; border-radius: 12px;
+  border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);
+  background: color-mix(in srgb, var(--ink, #000) 3.5%, transparent); }
+.tl-quote-head { display: flex; align-items: center; gap: .4rem; margin: 0 0 .35rem; min-width: 0; }
+.tl-quote-avatar { flex: 0 0 auto; width: 22px; height: 22px; border-radius: 50%; object-fit: cover; background: #fff; }
+.tl-quote-name { font-weight: 700; font-size: .85rem; color: var(--ink, inherit); white-space: nowrap; }
+.tl-quote-handle { color: var(--ink-soft, #888); font-size: .78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
+.tl-quote-body { line-height: 1.5; font-size: .92rem; color: var(--ink-soft, #555);
+  overflow-wrap: anywhere; max-height: 16em; overflow: hidden; }
+.tl-quote-body p { margin: .3rem 0; } .tl-quote-body p:first-child { margin-top: 0; } .tl-quote-body p:last-child { margin-bottom: 0; }
+.tl-quote-body a { color: var(--accent, #06c); }
+.tl-quote-media { display: block; margin: .45rem 0 0; }
+.tl-quote-media img { max-width: 100%; height: auto; border-radius: 8px; }
+
+/* Always show the FULL image at its natural ratio — never cropped, no letterbox. */
+.tl-media { display: flex; flex-direction: column; gap: .4rem; margin: .75rem 0 0; }
+.tl-media-img { display: block; border-radius: 12px; overflow: hidden;
+  background-image: linear-gradient(135deg,
+    color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2, var(--paper))) 0%,
+    color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2, var(--paper))) 100%); }
+.tl-media-img img { width: 100%; height: auto; display: block; background: #fff; transition: opacity .4s ease; }
+.tl-media-img img.is-loading { opacity: 0; }
+/* Reserve placeholder space while the (natural-aspect) feed image loads. */
+.tl-media-img:has(img.is-loading) { min-height: 220px; }
+.tl-media-video, .tl-media-audio { width: 100%; margin: .75rem 0 0; border-radius: 12px; display: block; }
+.tl-media-video { max-height: 480px; background: #000; }
+
+/* In Berichten a post sits inside a notification row, so it starts tighter and
+   its media stays modest: the row is a pointer to the post, not the post page. */
+.msg-note .tl-content { line-height: 1.5; }
+.msg-note .tl-quote, .msg-note .tl-media, .msg-note .tl-media-video, .msg-note .tl-media-audio { margin-top: .5rem; }
+.msg-note .tl-media-img:has(img.is-loading) { min-height: 120px; }
+.msg-note .tl-media-video { max-height: 320px; }
 </style>
Index: src/views/partials/tl-item.ejs
===================================================================
--- src/views/partials/tl-item.ejs	(revision 2708282985faa68002d3d9051b403c0e12b11dd9)
+++ src/views/partials/tl-item.ejs	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -10,46 +10,10 @@
           </div>
 
-          <%
-            var media = []; try { media = JSON.parse(p.media_json || '[]'); } catch (e) { media = []; }
-            var imgs = media.filter(function(m){ return m && m.url && (!m.type || /^image\//.test(m.type)); });
-            var vids = media.filter(function(m){ return m && m.url && m.type && /^video\//.test(m.type); });
-            var auds = media.filter(function(m){ return m && m.url && m.type && /^audio\//.test(m.type); });
-            var _nsfwVisual = !!p.nsfw && (imgs.length || vids.length || auds.length);
-            var _nsfwText = !!p.nsfw && !(imgs.length || vids.length || auds.length);
-          %>
-          <% if (_nsfwText) { %>
-            <div class="tl-content nsfw-media"><span class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: p.cw }) %></span><%- emojiHtml(p.content, p.emoji_json) %></div>
-          <% } else { %>
-            <div class="tl-content"><%- emojiHtml(p.content, p.emoji_json) %></div>
-          <% } %>
-          <%
-            // One card for both: a fediverse quote and an external embed look
-            // identical; only where they came from differs. An embed carries a
-            // title and a thumbnail, never an iframe.
-            var _quote = noteQuote(p.quote_json);
-            if (!_quote) {
-              var _emb = noteQuote(p.embed_json);
-              // The title comes from a third-party oEmbed provider, so it is
-              // escaped here: the quote card renders `content` as HTML (fine for
-              // AP content, which we sanitise on the way in, but not for this).
-              if (_emb) _quote = { url: _emb.url, author: _emb.author || (_emb.provider ? { name: _emb.provider } : null),
-                                   content: _emb.title ? ('<p>' + emojiName(_emb.title, null) + '</p>') : '', media: _emb.media || [] };
-            }
-          %>
-          <% if (_quote) { %><%- include('../partials/quote-card', { q: _quote }) %><% } %>
-          <% if (_nsfwVisual) { %><div class="nsfw-media"><% } %>
-          <% if (imgs.length) { %>
-            <div class="tl-media">
-              <% imgs.forEach(function(m){ %>
-                <a class="tl-media-img" href="<%= m.url %>" target="_blank" rel="noopener"><img src="<%= thumb(m.url, 1280) %>" alt="" loading="lazy" decoding="async"></a>
-              <% }); %>
-            </div>
-          <% } %>
-          <% vids.forEach(function(m){ %><video class="tl-media-video" src="<%= m.url %>" poster="<%= thumb(m.url, 1280) %>" controls preload="metadata" playsinline></video><% }); %>
-          <% /* A Klonkt audio post carries an embed player (embedHtml/embedUrl) that already
-                covers these tracks, so don't ALSO render the raw Audio attachments as bare
-                players here. Posts without a Klonkt embed (e.g. a plain remote audio) keep them. */ %>
-          <% if (!p.embedHtml && !p.embedUrl) { auds.forEach(function(m){ %><audio class="tl-media-audio" src="<%= m.url %>" controls preload="none"></audio><% }); } %>
-          <% if (_nsfwVisual) { %><div class="nsfw-veil"><%- include('../partials/nsfw-veil', { cw: p.cw }) %></div></div><% } %>
+          <% /* Content, quote/preview card and media: shared with Berichten and the
+                Guardian PWA so a post looks the same wherever it turns up.
+                suppressAudio: a Klonkt audio post carries an embed player below
+                that already covers these tracks, so don't ALSO render the raw
+                Audio attachments as bare players. */ %>
+          <%- include('../partials/note-body', { nb: Object.assign({}, p, { suppressAudio: !!(p.embedHtml || p.embedUrl) }) }) %>
 
           <% if (p.embedHtml) { %><div class="tl-embed"><%- p.embedHtml %></div><% } %>
Index: test/help-request-timeline.test.js
===================================================================
--- test/help-request-timeline.test.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
+++ test/help-request-timeline.test.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -0,0 +1,70 @@
+// A 🛟 help request (FEP-633c 5.2.1) is a message, not a post: it belongs in
+// Berichten and the Guardian PWA, never in de Krant. It used to land in both,
+// because the timeline insert only asked "top-level post from someone I follow"
+// and never looked at who the note was addressed to.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+const PUB = 'https://www.w3.org/ns/activitystreams#Public';
+const WARD = 'https://ward.test/ap/users/kid';
+const note = (extra) => ({ id: 'https://ward.test/notes/1', type: 'Note', content: '<p>hoi</p>', ...extra });
+
+db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
+db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'guard', 'Guard', 'u1', 1);
+
+test('a public post from someone we follow belongs in the timeline', () => {
+  assert.equal(AP.belongsInTimeline(note({ to: [PUB], cc: [`${WARD}/followers`] })), true);
+});
+
+test('a followers-only post still belongs there: you follow them', () => {
+  assert.equal(AP.belongsInTimeline(note({ to: [`${WARD}/followers`] })), true);
+});
+
+test('a direct note does not, whoever sent it', () => {
+  const dm = note({ to: ['https://test.example/ap/users/guard'] });
+  assert.equal(AP.noteVisibility(dm), 'direct');
+  assert.equal(AP.belongsInTimeline(dm), false, 'a DM is a message, not a feed post');
+});
+
+test('a help request is direct by construction, so it is refused too', () => {
+  const help = note({ to: ['https://test.example/ap/users/guard'], 'shaer:helpRequest': true });
+  assert.equal(AP.belongsInTimeline(help), false);
+});
+
+test('a reply never belongs in the timeline either: it belongs to its thread', () => {
+  assert.equal(AP.belongsInTimeline(note({ to: [PUB], inReplyTo: 'https://x.test/notes/9' })), false);
+});
+
+test('the self-heal drops a help request that was already cached as a post', async () => {
+  // Two rows with an identical shape; only the mention marks one as a 🛟.
+  for (const id of ['https://ward.test/notes/help', 'https://ward.test/notes/post']) {
+    db.prepare('INSERT INTO ap_timeline (id, slug, author_uri, content) VALUES (?,?,?,?)').run(id, 'guard', WARD, '<p>x</p>');
+  }
+  db.prepare('INSERT INTO ap_mentions (slug, object_uri, actor_uri, content, help_request) VALUES (?,?,?,?,1)')
+    .run('guard', 'https://ward.test/notes/help', WARD, '<p>x</p>');
+  // A public mention from someone you follow IS a post and must survive.
+  db.prepare('INSERT INTO ap_mentions (slug, object_uri, actor_uri, content, help_request) VALUES (?,?,?,?,0)')
+    .run('guard', 'https://ward.test/notes/post', WARD, '<p>x</p>');
+
+  db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('selfheal_version', '0')").run();
+  await AP.selfHealTimeline();
+
+  const left = db.prepare('SELECT id FROM ap_timeline WHERE slug = ?').all('guard').map((r) => r.id);
+  assert.ok(!left.includes('https://ward.test/notes/help'), 'the 🛟 is gone from the Krant');
+  assert.ok(left.includes('https://ward.test/notes/post'), 'the ordinary mention stays');
+});
+
+test('and it is still there for Berichten and the Guardian PWA', () => {
+  const help = db.prepare('SELECT * FROM ap_mentions WHERE object_uri = ?').get('https://ward.test/notes/help');
+  assert.ok(help, 'the mention row is untouched');
+  assert.equal(help.help_request, 1);
+  assert.ok(AP.getNotifications('guard', 20).some((n) => n.type === 'mention'), 'it shows up in Berichten');
+});
Index: test/note-body-shared.test.js
===================================================================
--- test/note-body-shared.test.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
+++ test/note-body-shared.test.js	(revision d9ad6c564eccd0e4c23c3d8c2c8a32334f2443bc)
@@ -0,0 +1,88 @@
+// One post, one rendering. De Krant, Berichten and the Guardian PWA all run a
+// note through partials/note-body, so a quote card, a link preview, the media
+// and the custom emojis show up wherever the post turns up — not only in the
+// feed it happened to arrive in.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+import path from 'path';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const { renderNoteBody } = await import('../src/middleware/render.js');
+const AP = (await import('../src/services/ActivityPubService.js')).default;
+
+const VIEWS = path.join(process.cwd(), 'src', 'views', 'partials');
+const EMOJI = JSON.stringify({ ':party:': 'https://cdn.test/party.png' });
+const MEDIA = JSON.stringify([{ url: 'https://cdn.test/capture.png', type: 'image/png' }]);
+const QUOTE = JSON.stringify({ url: 'https://q.test/notes/7', author: { name: 'Opie', handle: '@opie@q.test' }, content: '<p>het origineel</p>', media: [] });
+const EMBED = JSON.stringify({ url: 'https://video.test/watch?v=1', title: 'Een filmpje', provider: 'video.test', media: [{ url: 'https://video.test/thumb.jpg', type: 'image/jpeg' }] });
+
+test('the content renders with its custom emojis', () => {
+  const html = renderNoteBody({ content: '<p>hoi :party:</p>', emoji_json: EMOJI }, 'nl');
+  assert.match(html, /class="tl-content"/);
+  assert.match(html, /<img[^>]+class="emoji"[^>]+party\.png/, ':party: became an image, not a shortcode');
+});
+
+test('a quoted post renders as the quote card', () => {
+  const html = renderNoteBody({ content: '<p>kijk</p>', quote_json: QUOTE }, 'nl');
+  assert.match(html, /class="tl-quote"/);
+  assert.match(html, /het origineel/);
+  assert.match(html, /@opie@q\.test/);
+});
+
+test('an external link preview renders as that same card, with the title escaped', () => {
+  const html = renderNoteBody({ content: '<p>kijk</p>', embed_json: EMBED }, 'nl');
+  assert.match(html, /class="tl-quote"/, 'one card for both, only the origin differs');
+  assert.match(html, /Een filmpje/);
+  assert.ok(!/<iframe/i.test(html), 'a preview is a thumbnail, never an embedded player');
+});
+
+test('a quote wins over a link preview: only one card', () => {
+  const html = renderNoteBody({ content: '<p>x</p>', quote_json: QUOTE, embed_json: EMBED }, 'nl');
+  assert.equal((html.match(/class="tl-quote"/g) || []).length, 1);
+  assert.match(html, /het origineel/);
+  assert.ok(!html.includes('Een filmpje'));
+});
+
+test('media renders, and a sensitive note keeps its veil', () => {
+  const plain = renderNoteBody({ content: '<p>x</p>', media_json: MEDIA }, 'nl');
+  assert.match(plain, /class="tl-media-img"/);
+  const nsfw = renderNoteBody({ content: '<p>x</p>', media_json: MEDIA, nsfw: 1, cw: 'spoiler' }, 'nl');
+  assert.match(nsfw, /nsfw-media/, 'the veil survives outside de Krant too');
+});
+
+test('an empty note renders nothing at all', () => {
+  assert.equal(renderNoteBody({ content: '' }, 'nl'), '');
+  assert.equal(renderNoteBody(null, 'nl'), '');
+});
+
+test('de Krant and Berichten both go through the shared partial', () => {
+  for (const f of ['tl-item.ejs', 'msg-item.ejs']) {
+    const src = fs.readFileSync(path.join(VIEWS, f), 'utf8');
+    assert.match(src, /partials\/note-body/, `${f} must render a post through the shared partial`);
+    assert.ok(!/class="tl-media-img"/.test(src), `${f} must not carry its own copy of the media markup`);
+  }
+});
+
+test('Berichten receives the columns that partial needs', () => {
+  db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
+  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'me', 'Me', 'u1', 1);
+  db.prepare(`INSERT INTO ap_mentions (slug, object_uri, actor_uri, actor_name, actor_handle, content, emoji_json, media_json, quote_json, help_request)
+              VALUES ('me','https://r.test/notes/1','https://r.test/u/a','Anna','@a@r.test','<p>hoi :party:</p>',?,?,?,1)`).run(EMOJI, MEDIA, QUOTE);
+  const m = AP.getNotifications('me', 20).find((n) => n.type === 'mention');
+  assert.ok(m);
+  assert.equal(m.emoji_json, EMOJI);
+  assert.equal(m.media_json, MEDIA);
+  assert.equal(m.quote_json, QUOTE);
+  assert.equal(m.help_request, 1, 'so Berichten can mark a 🛟 as one');
+  // The proof: the same object, handed to the same renderer, comes out whole.
+  const html = renderNoteBody(m, 'nl');
+  assert.match(html, /party\.png/);
+  assert.match(html, /class="tl-quote"/);
+  assert.match(html, /class="tl-media-img"/);
+});
