Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision abcf51a249d35a16ca5d931a1bfd08dc6499fd5d)
+++ src/config/database.js	(revision 4838760bea2c166af5f7ad6643fc744e663c83fc)
@@ -718,4 +718,9 @@
   // time so buildNote (sync, also used by the outbox) needs no network.
   ensureColumn('posts', 'quote_uri', 'TEXT');     // the quoted object's id
+  // De kaart op je EIGEN post (shaer-k3f): dezelfde snapshots die ap_timeline
+  // voor binnenkomende posts draagt, maar dan voor wat je zelf publiceert --
+  // zonder deze twee kan de app een eigen post nooit als kaart tonen.
+  ensureColumn('posts', 'quote_json', 'TEXT');    // FEP-044f resolved quote snapshot
+  ensureColumn('posts', 'embed_json', 'TEXT');    // externe linkkaart (oEmbed/OG), thumbnail-only
   ensureColumn('posts', 'quote_actor', 'TEXT');   // its author, so we can address them
   ensureColumn('ap_timeline', 'embed_json', 'TEXT');       // resolved EXTERNAL embed (oEmbed/provider), thumbnail-only; gated per site (sites.external_embeds)
Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision abcf51a249d35a16ca5d931a1bfd08dc6499fd5d)
+++ src/routes/activitypub.js	(revision 4838760bea2c166af5f7ad6643fc744e663c83fc)
@@ -191,6 +191,26 @@
     // entries carry, so your own cards get a header too (avatar + name).
     const me = AP.selfAuthor(baseUrl(req), site);
+    // De kaart op je eigen post (shaer-k3f): dezelfde shaer:quote/shaer:embed
+    // die de tijdlijn voor andermans posts draagt, uit de snapshots die
+    // deliverCreate bij het publiceren opsloeg. Op note-id gekoppeld, want
+    // buildOutbox sorteert en mengt tracks erdoorheen. De embed alleen voor de
+    // BEARER en langs zijn eigen poort: een remote vriend krijgt hem niet
+    // (diens server resolvet en gate zelf bij ontvangst), en een ward zonder
+    // open embeds-poort krijgt hem hier net zo min als in de tijdlijn.
+    const byNote = new Map(posts.map((p) => [AP.noteId(baseUrl(req), p.id), p]));
+    const bearerEmbeds = bearer ? (() => {
+      const isWard = (() => { try { return Guardianship.listGuardians(bearer.site.slug).length > 0; } catch { return false; } })();
+      return Guardianship.externalEmbedsAllowed(bearer.site.external_embeds, isWard)
+        ? { playback: Guardianship.externalPlaybackAllowed(bearer.site.external_playback, isWard) } : null;
+    })() : null;
     for (const it of ob.orderedItems) {
-      if (it && it.object && typeof it.object === 'object') it.object['shaer:author'] = me;
+      if (it && it.object && typeof it.object === 'object') {
+        it.object['shaer:author'] = me;
+        const row = byNote.get(it.object.id);
+        if (row) {
+          it.object['shaer:quote'] = AP.timelineQuote(row.quote_json);
+          if (bearerEmbeds) it.object['shaer:embed'] = AP.timelineEmbed(row.embed_json, { playback: bearerEmbeds.playback });
+        }
+      }
     }
   }
@@ -813,4 +833,25 @@
 
 // ── Replies collection ── lets remote servers fetch a post's whole thread.
+// ── De composer-preview (shaer-k3f): een URL wordt alvast een kaart ──
+//
+// Bearer-only, net als de thread: dit is de eigen app die tijdens het typen
+// vraagt wat een link gaat worden. Dezelfde pijplijn als publiceren, dus de
+// preview kan niet iets beloven dat de post niet waarmaakt. De embed gaat
+// langs de eigen poort van de lezer -- een ward zonder open embeds-poort
+// krijgt in de composer geen kaart die zijn feed hem ook niet zou tonen.
+router.get('/ap/users/:slug/card', async (req, res) => {
+  const auth = OAuth.verifyBearer(req.headers.authorization);
+  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
+  const uit = await AP.previewCard(String(req.query.url || ''));
+  const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
+  const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard);
+  const playback = embedsAllowed && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
+  AP.sendAP(res, {
+    '@context': AP.AP_CONTEXT,
+    'shaer:quote': AP.timelineQuote(uit.quoteJson),
+    'shaer:embed': embedsAllowed ? AP.timelineEmbed(uit.embedJson, { playback }) : undefined,
+  }, 'private, no-store');
+});
+
 // ── De thread onder een post (shaer-tqz): ophalen, niet bewaren ────
 //
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision abcf51a249d35a16ca5d931a1bfd08dc6499fd5d)
+++ src/services/ActivityPubService.js	(revision 4838760bea2c166af5f7ad6643fc744e663c83fc)
@@ -2955,4 +2955,21 @@
       post2 = { ...post2, quote_uri: q.uri, quote_actor: q.actor || null };
     }
+  }
+  // De kaart op de eigen post (shaer-k3f), langs dezelfde pijplijn als een
+  // binnenkomende: een fediverse-quote wordt een quote-snapshot, anders
+  // probeert de link een externe kaart. VOOR de vroege return hieronder, want
+  // ook een post zonder volgers hoort zijn kaart te krijgen -- de app leest
+  // hem uit de outbox, niet uit een bezorging. Best-effort en eenmalig: wat
+  // hier niet lukt blijft een kale link, precies wat het was.
+  if (!post2.quote_json && !post2.embed_json) {
+    try {
+      if (post2.quote_uri) {
+        const qj = await resolveQuoteByUri(post2.quote_uri);
+        if (qj) { db.prepare('UPDATE posts SET quote_json = ? WHERE id = ?').run(qj, post.id); post2 = { ...post2, quote_json: qj }; }
+      } else {
+        const ej = await resolveExternalEmbed(post2.content || '');
+        if (ej) { db.prepare('UPDATE posts SET embed_json = ? WHERE id = ?').run(ej, post.id); post2 = { ...post2, embed_json: ej }; }
+      }
+    } catch { /* een kaart is nooit een blokkade voor de post zelf */ }
   }
   if (post2.quote_actor) {
@@ -4996,4 +5013,26 @@
 }
 
+/**
+ * De composer-preview (shaer-k3f): één URL langs exact dezelfde pijplijn als
+ * publiceren, zodat wat de preview toont ook is wat de post krijgt. Twee
+ * uitkomsten, hoogstens een gevuld: een AP-object wordt een quote-snapshot,
+ * een externe link probeert een kaart. Beide als JSON-string, dezelfde vorm
+ * als de kolommen -- de route serveert ze door timelineQuote/timelineEmbed en
+ * de gate, net als de tijdlijn.
+ */
+export async function previewCard(url) {
+  if (!/^https?:\/\//i.test(String(url || ''))) return {};
+  const html = `<a href="${String(url).replace(/"/g, '&quot;')}">x</a>`;
+  const q = await resolveOwnQuote(html);
+  if (q && q.uri) {
+    const quoteJson = await resolveQuoteByUri(q.uri).catch(() => null);
+    if (quoteJson) return { quoteJson };
+  } else {
+    const embedJson = await resolveExternalEmbed(html).catch(() => null);
+    if (embedJson) return { embedJson };
+  }
+  return {};
+}
+
 /** The first http(s) link in sanitized note HTML that is not a mention/hashtag. */
 export function firstExternalUrl(html) {
@@ -5051,4 +5090,10 @@
   const url = quoteHrefOf(note);
   if (!url) return null;
+  return resolveQuoteByUri(url);
+}
+
+/** Hetzelfde snapshot, maar vanaf een kale URI: eigen posts en de
+ *  composer-preview (shaer-k3f) kennen alleen de link, niet de tag-vorm. */
+async function resolveQuoteByUri(url) {
   const q = await apGetJson(url);
   if (!q || typeof q !== 'object') return null;
@@ -6270,5 +6315,5 @@
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
   gateOutgoingFollow, performApprovedFollow,
-  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs,
+  parseOwnPoll, pollTally, ownPollView, deliverPollUpdate, maybeCrawlThread, sendReport, localMentionSlugs, previewCard,
   autoBoostCount, boostedCount, setReaction, getReaction, getReactionsFor, canonicalReactionUri, migrateReactions, upsertBoostedNote, getCirkelPosts, getCirkelMembers, selfHealTimeline,
   getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
Index: test/own-card.test.js
===================================================================
--- test/own-card.test.js	(revision 4838760bea2c166af5f7ad6643fc744e663c83fc)
+++ test/own-card.test.js	(revision 4838760bea2c166af5f7ad6643fc744e663c83fc)
@@ -0,0 +1,86 @@
+// De kaart op je EIGEN post (shaer-k3f): een URL in wat je publiceert wordt
+// een quote- of embed-snapshot, langs dezelfde pijplijn als een binnenkomende
+// post -- en de composer-preview loopt door diezelfde pijplijn, zodat de
+// preview nooit iets belooft dat de post niet waarmaakt.
+//
+// In-memory SQLite, gestubde fetch op TEST-NET-3. Run: npm test
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://klonkt.test';
+
+const dbMod = await import('../src/config/database.js');
+const db = dbMod.default;
+dbMod.initializeDatabase();
+const AP = await import('../src/services/ActivityPubService.js');
+
+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) VALUES (?,?,?,?)').run('s1', 'schrijver', 'Schrijver', 'u1');
+const site = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
+
+const EXTERN = 'https://203.0.113.60/artikel';
+const APNOTE = 'https://203.0.113.61/notes/toot-1';
+const APAUTEUR = 'https://203.0.113.61/users/schrijfster';
+
+const OG_PAGINA = `<!doctype html><html><head>
+  <meta property="og:title" content="Een mooi artikel">
+  <meta property="og:image" content="https://203.0.113.60/plaatje.jpg">
+</head><body>tekst</body></html>`;
+
+globalThis.fetch = async (url, opts = {}) => {
+  const u = String(url);
+  const accepts = String((opts.headers && (opts.headers.Accept || opts.headers.accept)) || '');
+  const json = (o) => new Response(JSON.stringify(o), { status: 200, headers: { 'content-type': 'application/activity+json' } });
+  const html = (h) => new Response(h, { status: 200, headers: { 'content-type': 'text/html' } });
+  if (u === EXTERN) return html(OG_PAGINA);   // geen AP, wel OpenGraph
+  if (u === APNOTE) {
+    // Een echte fediverse-note: op de AP-vraag AP-json, anders een kale pagina.
+    if (accepts.includes('activity+json')) return json({ id: APNOTE, type: 'Note', attributedTo: APAUTEUR, content: '<p>de toot</p>', published: '2026-08-01T10:00:00Z' });
+    return html('<html><body>toot</body></html>');
+  }
+  if (u === APAUTEUR) return json({ id: APAUTEUR, type: 'Person', preferredUsername: 'schrijfster', name: 'Schrijfster', inbox: `${APAUTEUR}/inbox` });
+  return new Response('not found', { status: 404 });
+};
+
+test('een externe link in een eigen post wordt een embed-snapshot op de post', async () => {
+  db.prepare("INSERT INTO posts (id, site_id, author_id, slug, title, content, status) VALUES ('p1','s1','u1','p1','', ?, 'published')")
+    .run(`<p>Kijk: <a href="${EXTERN}">${EXTERN}</a></p>`);
+  const post = db.prepare("SELECT * FROM posts WHERE id = 'p1'").get();
+  await AP.deliverCreate(site, post);
+  const rij = db.prepare("SELECT quote_json, embed_json FROM posts WHERE id = 'p1'").get();
+  assert.ok(rij.embed_json, 'embed-snapshot opgeslagen');
+  const kaart = JSON.parse(rij.embed_json);
+  assert.equal(kaart.title, 'Een mooi artikel');
+  assert.ok(!rij.quote_json, 'geen quote: het is geen fediverse-object');
+});
+
+test('een fediverse-link wordt een quote-snapshot, geen embed', async () => {
+  db.prepare("INSERT INTO posts (id, site_id, author_id, slug, title, content, status) VALUES ('p2','s1','u1','p2','', ?, 'published')")
+    .run(`<p>Dit! <a href="${APNOTE}">${APNOTE}</a></p>`);
+  const post = db.prepare("SELECT * FROM posts WHERE id = 'p2'").get();
+  await AP.deliverCreate(site, post);
+  const rij = db.prepare("SELECT quote_uri, quote_json, embed_json FROM posts WHERE id = 'p2'").get();
+  assert.equal(rij.quote_uri, APNOTE);
+  assert.ok(rij.quote_json, 'quote-snapshot opgeslagen');
+  const kaart = JSON.parse(rij.quote_json);
+  assert.ok(kaart.content.includes('de toot'));
+  assert.equal(kaart.author.name, 'Schrijfster');
+  assert.ok(!rij.embed_json, 'geen embed naast de quote');
+});
+
+test('previewCard geeft voor dezelfde twee links dezelfde twee kaarten', async () => {
+  const extern = await AP.previewCard(EXTERN);
+  assert.ok(extern.embedJson, 'externe link -> embed');
+  assert.equal(JSON.parse(extern.embedJson).title, 'Een mooi artikel');
+  assert.ok(!extern.quoteJson);
+
+  const ap = await AP.previewCard(APNOTE);
+  assert.ok(ap.quoteJson, 'fediverse-link -> quote');
+  assert.equal(JSON.parse(ap.quoteJson).author.handle, '@schrijfster@203.0.113.61');
+  assert.ok(!ap.embedJson);
+
+  // Rommel is geen kaart en geen fout.
+  assert.deepEqual(await AP.previewCard('geen url'), {});
+});
