Index: src/services/ThemeService.js
===================================================================
--- src/services/ThemeService.js	(revision c9c70cc260bd2806f2c9e2e03fa4b46e2206ed7a)
+++ src/services/ThemeService.js	(revision b4aaaddef9ee9b6109381addc714faa320110e88)
@@ -1,2 +1,6 @@
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
 /**
  * ThemeService — Palette and theme management
@@ -207,4 +211,132 @@
     };
   }
+
+  // ── Lay-outthema's (experimental/theme, op weg naar 2.0) ─────────────
+  //
+  // Een lay-out is een PAKKET: een map met een theme.json en een stylesheet.
+  // De ingebouwde staan in src/themes/<id>/; een thema van buiten installeren
+  // komt later en gaat door dezelfde lader, zodat er één vorm is.
+  //
+  // Wie kiest: de BEHEERDER van een site (sites.layout). Een bezoeker niet --
+  // `?layout=` is alleen een voorproefje voor wie de site beheert.
+
+  /** Het DOM-contract dat thema's mogen aanspreken. Gaat omhoog zodra de
+   *  markup verandert op een manier die thema's breekt; een thema op een ander
+   *  contract valt dan terug op de standaardlay-out in plaats van half te
+   *  werken. */
+  static LAYOUT_CONTRACT = 1;
+
+  static THEMES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'themes');
+
+  /** Een id is een naam en geen pad. Kort, kleine letters, punten en
+   *  streepjes, zodat er niets anders van te maken is dan een mapnaam. */
+  static isLayoutId(id) {
+    return typeof id === 'string' && /^[a-z0-9][a-z0-9.-]{0,63}$/.test(id) && !id.includes('..');
+  }
+
+  /** Een theme.json die klopt, of null -- met de reden in de log, want een
+   *  thema dat stil wegvalt is een raadsel voor wie het installeerde. */
+  static validateLayoutManifest(m, dirName) {
+    const fout = (waarom) => { console.warn(`[theme] ${dirName || '?'}: ${waarom}`); return null; };
+    if (!m || typeof m !== 'object') return fout('geen geldige theme.json');
+    if (!ThemeService.isLayoutId(m.id)) return fout(`ongeldig id ${JSON.stringify(m.id)}`);
+    if (dirName && m.id !== dirName) return fout(`id ${m.id} hoort in een map met die naam`);
+    if (typeof m.name !== 'string' || !m.name.trim()) return fout('naam ontbreekt');
+    if (typeof m.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(m.version)) return fout('versie is geen x.y.z');
+    if (m.contract !== ThemeService.LAYOUT_CONTRACT) return fout(`contract ${m.contract}, deze Klonkt kent ${ThemeService.LAYOUT_CONTRACT}`);
+    if (typeof m.stylesheet !== 'string' || !/^[a-z0-9][a-z0-9._-]*\.css$/i.test(m.stylesheet)) return fout('stylesheet ontbreekt of is geen bestandsnaam');
+    return {
+      id: m.id,
+      name: m.name.trim().slice(0, 80),
+      description: typeof m.description === 'string' ? m.description.slice(0, 500) : '',
+      version: m.version,
+      contract: m.contract,
+      stylesheet: m.stylesheet,
+      author: typeof m.author === 'string' ? m.author.slice(0, 200) : '',
+      license: typeof m.license === 'string' ? m.license.slice(0, 64) : '',
+      builtIn: true,
+    };
+  }
+
+  static _layouts = null;
+
+  /** Alle lay-outs die deze Klonkt kent, op naam. Eén keer gelezen per proces:
+   *  een pakket verandert niet terwijl de server draait, en de shell vraagt
+   *  hier bij elk verzoek om. */
+  static listLayouts() {
+    if (ThemeService._layouts) return ThemeService._layouts;
+    const uit = [];
+    let mappen = [];
+    try { mappen = fs.readdirSync(ThemeService.THEMES_DIR, { withFileTypes: true }); } catch { mappen = []; }
+    for (const d of mappen) {
+      if (!d.isDirectory() || !ThemeService.isLayoutId(d.name)) continue;
+      const dir = path.join(ThemeService.THEMES_DIR, d.name);
+      let ruw;
+      try { ruw = JSON.parse(fs.readFileSync(path.join(dir, 'theme.json'), 'utf8')); } catch { console.warn(`[theme] ${d.name}: theme.json onleesbaar`); continue; }
+      const m = ThemeService.validateLayoutManifest(ruw, d.name);
+      if (!m) continue;
+      if (!fs.existsSync(path.join(dir, m.stylesheet))) { console.warn(`[theme] ${d.name}: ${m.stylesheet} ontbreekt`); continue; }
+      uit.push(m);
+    }
+    uit.sort((a, b) => a.name.localeCompare(b.name));
+    ThemeService._layouts = uit;
+    return uit;
+  }
+
+  static getLayout(id) {
+    if (!ThemeService.isLayoutId(id)) return null;
+    return ThemeService.listLayouts().find((m) => m.id === id) || null;
+  }
+
+  /** Waar een thema zijn bestanden laat zien. De versie zit in het pad, dus
+   *  een nieuwe versie is een nieuwe URL en de cache van een jaar kan blijven. */
+  static layoutBaseUrl(m) { return `/themes/${m.id}/${m.version}`; }
+  static layoutStylesheetUrl(m) { return `${ThemeService.layoutBaseUrl(m)}/${m.stylesheet}`; }
+  static layoutManifestUrl(m) { return `${ThemeService.layoutBaseUrl(m)}/theme.json`; }
+
+  /**
+   * Welke lay-out deze pagina draagt, of null voor de gewone kop.
+   *
+   *   - Beheerpagina's ALTIJD de gewone kop: een kapot thema mag de beheerder
+   *     nooit buitensluiten van de instelling waarmee hij het uitzet.
+   *   - `preview` (uit ?layout=) alleen voor wie de site beheert. Een bezoeker
+   *     kiest niet; de site kiest.
+   *   - Anders wat de site gekozen heeft.
+   */
+  static resolveLayout(site, { isAdminPage = false, canManage = false, preview = null } = {}) {
+    if (isAdminPage) return null;
+    if (canManage && typeof preview === 'string') {
+      if (preview === 'top') return null;
+      const p = ThemeService.getLayout(preview);
+      if (p) return p;
+    }
+    return site && site.layout ? ThemeService.getLayout(site.layout) : null;
+  }
+
+  /** Het bestand achter /themes/<id>/<versie>/<naam>, of null. Alleen wat het
+   *  pakket zelf noemt -- de stylesheet en de theme.json -- en alleen van de
+   *  versie die er nu is. Geen pad uit de URL komt ooit in een join terecht. */
+  static layoutFile(id, version, file) {
+    const m = ThemeService.getLayout(id);
+    if (!m || version !== m.version) return null;
+    if (file !== m.stylesheet && file !== 'theme.json') return null;
+    return path.join(ThemeService.THEMES_DIR, m.id, file);
+  }
+
+  /** Wat de actor erover zegt (shaer:theme), zodat Shaer het thema van zijn
+   *  eigen site kan ophalen in plaats van er zelf een te kiezen. */
+  static layoutActorProp(base, site) {
+    const m = site && site.layout ? ThemeService.getLayout(site.layout) : null;
+    if (!m) return {};
+    return {
+      'shaer:theme': {
+        type: 'shaer:Theme',
+        id: `${base}${ThemeService.layoutManifestUrl(m)}`,
+        name: m.name,
+        'shaer:version': m.version,
+        'shaer:contract': m.contract,
+      },
+    };
+  }
 }
 
