Index: package-lock.json
===================================================================
--- package-lock.json	(revision 5eef817483804be6a13b52f068413569df6810b0)
+++ package-lock.json	(revision 69815b232bc91b7504ea8b28d3e2f66949f216ed)
@@ -1,5 +1,5 @@
 {
   "name": "klonkt",
-  "version": "1.0.0-alpha.1",
+  "version": "1.0.0-beta.3",
   "lockfileVersion": 3,
   "requires": true,
@@ -7,6 +7,8 @@
     "": {
       "name": "klonkt",
-      "version": "1.0.0-alpha.1",
-      "dependencies": {
+      "version": "1.0.0-beta.3",
+      "license": "AGPL-3.0-or-later",
+      "dependencies": {
+        "@resvg/resvg-js": "^2.6.2",
         "bcryptjs": "^2.4.3",
         "better-sqlite3": "^11.3.0",
@@ -60,4 +62,219 @@
         "readable-stream": "^3.0.2",
         "typedarray": "^0.0.6"
+      }
+    },
+    "node_modules/@resvg/resvg-js": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz",
+      "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==",
+      "license": "MPL-2.0",
+      "engines": {
+        "node": ">= 10"
+      },
+      "optionalDependencies": {
+        "@resvg/resvg-js-android-arm-eabi": "2.6.2",
+        "@resvg/resvg-js-android-arm64": "2.6.2",
+        "@resvg/resvg-js-darwin-arm64": "2.6.2",
+        "@resvg/resvg-js-darwin-x64": "2.6.2",
+        "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2",
+        "@resvg/resvg-js-linux-arm64-gnu": "2.6.2",
+        "@resvg/resvg-js-linux-arm64-musl": "2.6.2",
+        "@resvg/resvg-js-linux-x64-gnu": "2.6.2",
+        "@resvg/resvg-js-linux-x64-musl": "2.6.2",
+        "@resvg/resvg-js-win32-arm64-msvc": "2.6.2",
+        "@resvg/resvg-js-win32-ia32-msvc": "2.6.2",
+        "@resvg/resvg-js-win32-x64-msvc": "2.6.2"
+      }
+    },
+    "node_modules/@resvg/resvg-js-android-arm-eabi": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz",
+      "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@resvg/resvg-js-android-arm64": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz",
+      "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@resvg/resvg-js-darwin-arm64": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz",
+      "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@resvg/resvg-js-darwin-x64": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz",
+      "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz",
+      "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@resvg/resvg-js-linux-arm64-gnu": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz",
+      "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@resvg/resvg-js-linux-arm64-musl": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz",
+      "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@resvg/resvg-js-linux-x64-gnu": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz",
+      "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@resvg/resvg-js-linux-x64-musl": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz",
+      "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@resvg/resvg-js-win32-arm64-msvc": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz",
+      "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@resvg/resvg-js-win32-ia32-msvc": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz",
+      "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@resvg/resvg-js-win32-x64-msvc": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz",
+      "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
       }
     },
Index: package.json
===================================================================
--- package.json	(revision 5eef817483804be6a13b52f068413569df6810b0)
+++ package.json	(revision 69815b232bc91b7504ea8b28d3e2f66949f216ed)
@@ -15,4 +15,5 @@
   },
   "dependencies": {
+    "@resvg/resvg-js": "^2.6.2",
     "bcryptjs": "^2.4.3",
     "better-sqlite3": "^11.3.0",
Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision 5eef817483804be6a13b52f068413569df6810b0)
+++ src/middleware/render.js	(revision 69815b232bc91b7504ea8b28d3e2f66949f216ed)
@@ -137,4 +137,6 @@
     cspNonce: () => '',
     currentPath: req.path,
+    // Absolute origin (for building absolute URLs like the generated og:image).
+    ogOrigin: (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host') || ''}`).replace(/\/+$/, ''),
     ...data,
   };
Index: src/routes/og.js
===================================================================
--- src/routes/og.js	(revision 69815b232bc91b7504ea8b28d3e2f66949f216ed)
+++ src/routes/og.js	(revision 69815b232bc91b7504ea8b28d3e2f66949f216ed)
@@ -0,0 +1,29 @@
+/**
+ * GET /og/:slug.png — themed Open Graph card for a site (1200x630 PNG).
+ * Generated from the site's palette + accent (see OgImageService), cached.
+ * Used as the default og:image so every site has a branded social preview.
+ */
+import express from 'express';
+import db from '../config/database.js';
+import { ogImageFor } from '../services/OgImageService.js';
+
+const router = express.Router();
+
+router.get('/:slug.png', (req, res) => {
+  let site;
+  try {
+    site = db.prepare(
+      'SELECT slug, title, tagline, description, palette, accent FROM sites WHERE slug = ?'
+    ).get(req.params.slug);
+  } catch { /* db error → 404 below */ }
+  if (!site) return res.status(404).end();
+
+  const png = ogImageFor(site);
+  if (!png) return res.status(404).end(); // resvg unavailable → no card (graceful)
+
+  res.set('Content-Type', 'image/png');
+  res.set('Cache-Control', 'public, max-age=86400');
+  return res.send(png);
+});
+
+export default router;
Index: src/server.js
===================================================================
--- src/server.js	(revision 5eef817483804be6a13b52f068413569df6810b0)
+++ src/server.js	(revision 69815b232bc91b7504ea8b28d3e2f66949f216ed)
@@ -63,4 +63,5 @@
 import adminEpkRoutes from './routes/admin-epk.js';
 import changelogRoutes from './routes/changelog.js';
+import ogRoutes from './routes/og.js';
 
 // SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one
@@ -222,4 +223,8 @@
 app.use(federationRoutes);
 
+// Themed OG cards (/og/:slug.png) — resolve the site by slug themselves, so they
+// run before resolveSite and need no site context.
+app.use('/og', ogRoutes);
+
 app.use(resolveSite);
 app.use(loadAudioTracks);
Index: src/services/OgImageService.js
===================================================================
--- src/services/OgImageService.js	(revision 69815b232bc91b7504ea8b28d3e2f66949f216ed)
+++ src/services/OgImageService.js	(revision 69815b232bc91b7504ea8b28d3e2f66949f216ed)
@@ -0,0 +1,112 @@
+/**
+ * OgImageService — generates a themed Open Graph card (1200x630 PNG) per site,
+ * derived from the site's palette + accent, so every site has a branded social
+ * preview even without uploading one. SVG is hand-built and rasterized with
+ * @resvg/resvg-js. Result is cached on disk (keyed by the theming inputs).
+ *
+ * Graceful: if @resvg/resvg-js can't load (exotic platform), ogImageFor()
+ * returns null and the caller falls back to no/other og:image — never throws.
+ */
+import fs from 'fs';
+import path from 'path';
+import crypto from 'crypto';
+import { fileURLToPath } from 'url';
+import { createRequire } from 'module';
+import ThemeService from './ThemeService.js';
+
+const require = createRequire(import.meta.url);
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const FONT = path.join(__dirname, '..', 'assets', 'fonts', 'fraunces-og.ttf');
+const DATA_DIR = path.dirname(process.env.DATABASE_PATH || './storage/database.sqlite');
+const CACHE_DIR = path.join(DATA_DIR, 'og');
+const TEMPLATE_VERSION = 1; // bump to invalidate all cached cards after a design change
+
+let _Resvg = null, _tried = false;
+function getResvg() {
+  if (_tried) return _Resvg;
+  _tried = true;
+  try { _Resvg = require('@resvg/resvg-js').Resvg; } catch { _Resvg = null; }
+  return _Resvg;
+}
+
+// ── tiny colour helpers ───────────────────────────────────────────
+function hx(h) {
+  h = String(h || '').replace('#', '');
+  if (h.length === 3) h = h.split('').map((c) => c + c).join('');
+  return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) || 0);
+}
+function rgb(a) {
+  return '#' + a.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
+}
+function mix(a, b, t) { const A = hx(a), B = hx(b); return rgb(A.map((v, i) => v + (B[i] - v) * t)); }
+function esc(s) { return String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c])); }
+
+function buildSvg(site, palette, accent) {
+  const pal = (ThemeService.PALETTES[palette] || ThemeService.PALETTES.klonkt).dark;
+  const paper = pal.paper, ink = pal.ink;
+  const paper2 = mix(paper, ink, 0.08);
+  const muted = mix(ink, paper, 0.42);
+
+  let title = (site.title || 'Klonkt').trim();
+  let tag = (site.tagline || site.description || '').trim();
+  if (title.length > 38) title = title.slice(0, 37) + '…';
+  if (tag.length > 74) tag = tag.slice(0, 73) + '…';
+  const tsize = title.length <= 12 ? 100 : title.length <= 20 ? 82 : title.length <= 30 ? 64 : 54;
+
+  return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">
+  <defs>
+    <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="${paper}"/><stop offset="1" stop-color="${paper2}"/>
+    </linearGradient>
+    <radialGradient id="glow" cx="0.85" cy="0.12" r="0.7">
+      <stop offset="0" stop-color="${accent}" stop-opacity="0.20"/>
+      <stop offset="1" stop-color="${accent}" stop-opacity="0"/>
+    </radialGradient>
+  </defs>
+  <rect width="1200" height="630" fill="url(#bg)"/>
+  <rect width="1200" height="630" fill="url(#glow)"/>
+  <rect x="0" y="0" width="14" height="630" fill="${accent}"/>
+  <g transform="translate(96,232)">
+    <rect x="0"  y="14" width="11" height="34" rx="3" fill="${accent}"/>
+    <rect x="18" y="0"  width="11" height="48" rx="3" fill="${accent}"/>
+    <rect x="36" y="22" width="11" height="26" rx="3" fill="${accent}"/>
+    <rect x="54" y="8"  width="11" height="40" rx="3" fill="${accent}"/>
+  </g>
+  <text x="96" y="400" font-size="${tsize}" fill="${ink}">${esc(title)}</text>
+  ${tag ? `<text x="98" y="462" font-size="34" fill="${muted}">${esc(tag)}</text>` : ''}
+  <text x="96" y="566" font-size="30" fill="${accent}">klonkt</text>
+</svg>`;
+}
+
+/**
+ * Returns a PNG Buffer of the site's OG card (cached), or null if generation
+ * isn't possible. `site` needs: slug, title, palette, accent, tagline/description.
+ */
+export function ogImageFor(site) {
+  const Resvg = getResvg();
+  if (!Resvg || !site || !site.slug) return null;
+
+  const palette = ThemeService.PALETTES[site.palette] ? site.palette : 'klonkt';
+  const accent = site.accent || (ThemeService.PALETTES[palette] || ThemeService.PALETTES.klonkt).dark.accent;
+  const key = crypto.createHash('sha1')
+    .update([TEMPLATE_VERSION, site.slug, palette, accent, site.title || '', site.tagline || site.description || ''].join('\x1f'))
+    .digest('hex').slice(0, 16);
+  const file = path.join(CACHE_DIR, key + '.png');
+
+  try { return fs.readFileSync(file); } catch { /* not cached yet */ }
+
+  try {
+    const svg = buildSvg(site, palette, accent);
+    const png = new Resvg(svg, {
+      font: { fontFiles: [FONT], loadSystemFonts: false },
+      fitTo: { mode: 'width', value: 1200 },
+    }).render().asPng();
+    try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(file, png); } catch { /* cache best-effort */ }
+    return png;
+  } catch {
+    return null;
+  }
+}
+
+export default { ogImageFor };
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 5eef817483804be6a13b52f068413569df6810b0)
+++ src/views/shell.ejs	(revision 69815b232bc91b7504ea8b28d3e2f66949f216ed)
@@ -57,7 +57,14 @@
   ? socialDescr
   : (safeSite.default_description || safeSite.description || '');
-const _socialImage = (typeof socialImage !== 'undefined' && socialImage)
-  ? socialImage
-  : (safeSite.og_image_default || safeSite.default_cover || '');
+// og:image — custom (post/site) first; otherwise the auto-generated themed card
+// (/og/<slug>.png), so every site has a branded social preview by default.
+let _socialImage = '', _ogGenerated = false;
+if (typeof socialImage !== 'undefined' && socialImage) _socialImage = socialImage;
+else if (safeSite.og_image_default) _socialImage = safeSite.og_image_default;
+else if (safeSite.default_cover) _socialImage = safeSite.default_cover;
+else if (safeSite.slug && typeof ogOrigin !== 'undefined' && ogOrigin) {
+  _socialImage = ogOrigin + '/og/' + encodeURIComponent(safeSite.slug) + '.png';
+  _ogGenerated = true;
+}
 const _ogType = isPostPage ? 'article' : 'website';
 
@@ -139,4 +146,9 @@
 <meta property="og:image"     content="<%= _e(_socialImage) %>">
 <meta property="og:image:alt" content="<%= _e(_socialTitle) %>">
+<% if (_ogGenerated) { %>
+<meta property="og:image:width"  content="1200">
+<meta property="og:image:height" content="630">
+<meta property="og:image:type"   content="image/png">
+<% } %>
 <% } %>
 <% if (_canonical) { %><meta property="og:url" content="<%= _e(_canonical) %>"><% } %>
