Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 43273c9c31fa8a9bd6bb1d527b68b1a63f7aa485)
+++ src/config/database.js	(revision d18c60e0878b426798d32187e85100a2262f1430)
@@ -89,4 +89,5 @@
   ensureColumn('posts', 'nsfw',     'INTEGER DEFAULT 0');  // sensitive content → blur + click-to-reveal; fediverse sensitive
   ensureColumn('posts', 'cover_video_url', 'TEXT');        // muted loop MP4 for an animated cover (Safari-smooth)
+  ensureColumn('posts', 'cover_alt', 'TEXT');              // alt text / description for the cover (a11y → AS2 attachment `name`)
   ensureColumn('posts', 'content_warning', 'TEXT');        // custom CW label (empty = default "Gevoelige inhoud")
   ensureColumn('posts', 'type',    "TEXT DEFAULT 'post'");  // post | foto | video | audio
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 43273c9c31fa8a9bd6bb1d527b68b1a63f7aa485)
+++ src/routes/posts.js	(revision d18c60e0878b426798d32187e85100a2262f1430)
@@ -246,4 +246,5 @@
   const nsfw = req.body.nsfw ? 1 : 0;
   const cw = (req.body.content_warning || '').trim().slice(0, 200);
+  const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
 
   // Content arrives as user-authored HTML from the WYSIWYG editor — sanitize
@@ -284,11 +285,11 @@
     INSERT INTO posts (
       id, site_id, slug, author_id, title, content, excerpt,
-      status, cover_image_url, cover_video_url, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
+      status, cover_image_url, cover_video_url, cover_alt, pinned, tags, type, noindex, fan_only, nsfw, content_warning, poll_json, publish_at,
       created_at, updated_at, published_at
-    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
   `).run(
     postId, site.id, finalSlug, req.session.user.id,
     title || finalSlug, cleanContent, excerpt || '',
-    finalStatus, cover_image_url || null, (req.body.cover_video_url || null), parsePinnedRank(pinned),
+    finalStatus, cover_image_url || null, (req.body.cover_video_url || null), coverAlt, parsePinnedRank(pinned),
     JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
     finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
@@ -312,5 +313,5 @@
       ActivityPubService.deliverCreate(site, {
         id: postId, slug: finalSlug, title: title || finalSlug,
-        content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null,
+        content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt,
         published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
       }).catch(() => { /* best-effort */ });
@@ -379,4 +380,5 @@
   const nsfw = req.body.nsfw ? 1 : 0;
   const cw = (req.body.content_warning || '').trim().slice(0, 200);
+  const coverAlt = (req.body.cover_alt || '').trim().slice(0, 1500) || null; // cover alt text (a11y)
   const newSlug = req.body.slug;
   const action = req.body.action || 'save';
@@ -422,5 +424,5 @@
     UPDATE posts SET
       title = ?, content = ?, excerpt = ?, status = ?,
-      cover_image_url = ?, cover_video_url = ?, pinned = ?, tags = ?,
+      cover_image_url = ?, cover_video_url = ?, cover_alt = ?, pinned = ?, tags = ?,
       type = ?, noindex = ?, fan_only = ?, nsfw = ?, content_warning = ?, poll_json = ?, publish_at = ?,
       slug = ?, published_at = ?, updated_at = ?
@@ -428,5 +430,5 @@
   `).run(
     title, cleanContent, excerpt, finalStatus,
-    cover_image_url || null, (req.body.cover_video_url || null), parsePinnedRank(pinned),
+    cover_image_url || null, (req.body.cover_video_url || null), coverAlt, parsePinnedRank(pinned),
     JSON.stringify((tags || '').split(',').map(t => t.trim()).filter(Boolean)),
     finalType, noindex ? 1 : 0, fanOnly, nsfw, cw, pollJson, publishAt,
@@ -454,5 +456,5 @@
     const apPost = {
       id: post.id, slug: finalSlug, title: title || finalSlug,
-      content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null,
+      content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt,
       published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
     };
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 43273c9c31fa8a9bd6bb1d527b68b1a63f7aa485)
+++ src/services/ActivityPubService.js	(revision d18c60e0878b426798d32187e85100a2262f1430)
@@ -262,13 +262,18 @@
   // An animated cover federates as the muted loop MP4 (→ a Video attachment): animated WebP is
   // unreliable on Mastodon and its iOS apps; the MP4 plays everywhere. Else the still cover image.
-  if (post.cover_video_url && !noImages) urls.push(abs(post.cover_video_url));
-  else if (post.cover_image_url && !noImages) urls.push(abs(post.cover_image_url));
+  // Each entry carries the media URL + its alt text (federated as the AS2 attachment `name`, for a11y).
+  if (post.cover_video_url && !noImages) urls.push({ url: abs(post.cover_video_url), name: post.cover_alt || '' });
+  else if (post.cover_image_url && !noImages) urls.push({ url: abs(post.cover_image_url), name: post.cover_alt || '' });
   let body = post.content || '';
   // Only federate inline images we can actually serve: absolute http(s) URLs, or our own
   // /media/ uploads. A relative path we don't host (e.g. a stale /images/... ref) would 404
-  // and show up as a black tile in Mastodon's attachment grid.
-  if (!noImages) for (const m of body.matchAll(/<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gi)) {
-    const src = m[1];
-    if (/^https?:\/\//i.test(src) || src.startsWith('/media/')) urls.push(abs(src));
+  // and show up as a black tile in Mastodon's attachment grid. Carry the <img alt="…"> through
+  // as the attachment description.
+  if (!noImages) for (const m of body.matchAll(/<img\b[^>]*>/gi)) {
+    const tag = m[0];
+    const src = (tag.match(/\bsrc="([^"]+)"/i) || [])[1];
+    if (!src || !(/^https?:\/\//i.test(src) || src.startsWith('/media/'))) continue;
+    const alt = (tag.match(/\balt="([^"]*)"/i) || [])[1] || '';
+    urls.push({ url: abs(src), name: alt });
   }
   body = body.replace(/<img\b[^>]*>/gi, '');
@@ -345,9 +350,11 @@
   }
   const seen = new Set();
-  const attachment = urls.filter(Boolean)
-    .filter((u) => { if (seen.has(u)) return false; seen.add(u); return true; })
-    .map((u) => { const mt = mediaType(u); // specific AS2 subtype (Image/Audio/Video) over generic Document
+  const attachment = urls.filter((x) => x && x.url)
+    .filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; })
+    .map((x) => { const mt = mediaType(x.url); // specific AS2 subtype (Image/Audio/Video) over generic Document
       const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
-      return { type: ty, mediaType: mt, url: u }; });
+      const a = { type: ty, mediaType: mt, url: x.url };
+      if (x.name) a.name = String(x.name).slice(0, 1500); // alt text / description (AS2 `name`)
+      return a; });
   for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
 
@@ -385,5 +392,5 @@
   if (post.cover_image_url && noImages) {
     const cov = abs(post.cover_image_url);
-    if (cov) note.image = { type: 'Image', mediaType: mediaType(cov), url: cov };
+    if (cov) { note.image = { type: 'Image', mediaType: mediaType(cov), url: cov }; if (post.cover_alt) note.image.name = String(post.cover_alt).slice(0, 1500); }
   }
   // Experiment (mirrors PeerTube / schema.org `embedUrl`): point at the GATED player page
Index: src/services/Scheduler.js
===================================================================
--- src/services/Scheduler.js	(revision 43273c9c31fa8a9bd6bb1d527b68b1a63f7aa485)
+++ src/services/Scheduler.js	(revision d18c60e0878b426798d32187e85100a2262f1430)
@@ -15,5 +15,5 @@
   try {
     const due = db.prepare(`
-      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.fan_only, p.nsfw, p.content_warning, p.poll_json,
+      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.cover_alt, p.fan_only, p.nsfw, p.content_warning, p.poll_json,
              p.published_at, p.publish_at, p.created_at, u.username
       FROM posts p JOIN users u ON u.id = p.author_id
@@ -38,5 +38,5 @@
           ActivityPubService.deliverCreate(site, {
             id: p.id, slug: p.slug, title: p.title || p.slug,
-            content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null,
+            content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null, cover_alt: p.cover_alt,
             published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw, content_warning: p.content_warning, poll_json: p.poll_json,
           }).catch(() => { /* best-effort */ });
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 43273c9c31fa8a9bd6bb1d527b68b1a63f7aa485)
+++ src/services/i18n.js	(revision d18c60e0878b426798d32187e85100a2262f1430)
@@ -637,4 +637,6 @@
     'pedit.f_cover_url': 'Cover URL',
     'pedit.cover_url_placeholder': '/media/…  of https://…  (of upload met de knop)',
+    'pedit.f_cover_alt': 'Alt-tekst (beschrijving)',
+    'pedit.cover_alt_placeholder': 'Beschrijf de afbeelding voor schermlezers',
     'pedit.cover_upload_btn': 'Upload nieuwe cover',
     'pedit.s_content': 'Content',
@@ -1555,4 +1557,6 @@
     'pedit.f_cover_url': 'Cover URL',
     'pedit.cover_url_placeholder': '/media/…  or https://…  (or upload with the button)',
+    'pedit.f_cover_alt': 'Alt text (description)',
+    'pedit.cover_alt_placeholder': 'Describe the image for screen readers',
     'pedit.cover_upload_btn': 'Upload new cover',
     'pedit.s_content': 'Content',
@@ -2473,4 +2477,6 @@
     'pedit.f_cover_url': 'Titelbild-URL',
     'pedit.cover_url_placeholder': '/media/…  oder https://…  (oder per Schaltfläche hochladen)',
+    'pedit.f_cover_alt': 'Alt-Text (Beschreibung)',
+    'pedit.cover_alt_placeholder': 'Beschreibe das Bild für Screenreader',
     'pedit.cover_upload_btn': 'Neues Titelbild hochladen',
     'pedit.s_content': 'Inhalt',
Index: src/views/pages/post-edit.ejs
===================================================================
--- src/views/pages/post-edit.ejs	(revision 43273c9c31fa8a9bd6bb1d527b68b1a63f7aa485)
+++ src/views/pages/post-edit.ejs	(revision d18c60e0878b426798d32187e85100a2262f1430)
@@ -105,4 +105,10 @@
                    inputmode="url" autocapitalize="none" spellcheck="false"
                    placeholder="<%= t('pedit.cover_url_placeholder') %>">
+          </label>
+          <label class="pe-field">
+            <span><%= t('pedit.f_cover_alt') %></span>
+            <input type="text" name="cover_alt" id="cover-alt-field" maxlength="1500"
+                   value="<%= post.cover_alt || '' %>"
+                   placeholder="<%= t('pedit.cover_alt_placeholder') %>">
           </label>
           <%# Muted loop MP4 for an animated cover (auto-made from an animated WebP). Hidden — set by the uploader. %>
Index: src/views/pages/post.ejs
===================================================================
--- src/views/pages/post.ejs	(revision 43273c9c31fa8a9bd6bb1d527b68b1a63f7aa485)
+++ src/views/pages/post.ejs	(revision d18c60e0878b426798d32187e85100a2262f1430)
@@ -20,5 +20,5 @@
   <% if (post.cover_image_url) { %>
     <figure class="post-cover">
-      <img src="<%= post.cover_image_url %>" alt=""<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
+      <img src="<%= post.cover_image_url %>" alt="<%= post.cover_alt || '' %>"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
     </figure>
   <% } %>
