Index: test/layout-themes.test.js
===================================================================
--- test/layout-themes.test.js	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
+++ test/layout-themes.test.js	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
@@ -0,0 +1,99 @@
+// Lay-outthema's (experimental/theme): een thema is een pakket, de beheerder
+// kiest, een bezoeker niet, en het beheer houdt altijd de gewone kop.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'fs';
+
+process.env.DATABASE_PATH = ':memory:';
+process.env.PUBLIC_BASE_URL = 'https://test.example';
+
+const dbMod = await import('../src/config/database.js');
+dbMod.initializeDatabase();
+const T = (await import('../src/services/ThemeService.js')).default;
+
+// ── Het pakket ────────────────────────────────────────────────────
+
+test('de zijbalk is een ingebouwd thema met het huidige contract', () => {
+  const s = T.getLayout('sidebar');
+  assert.ok(s, 'het pakket wordt gevonden en klopt');
+  assert.equal(s.contract, T.LAYOUT_CONTRACT);
+  assert.match(s.version, /^\d+\.\d+\.\d+$/);
+  assert.equal(s.stylesheet, 'layout.css');
+});
+
+test('elke regel van een ingebouwd thema staat onder zijn eigen data-layout', () => {
+  // Zo raakt een thema niets zolang het niet gekozen is. Voor ingebouwde
+  // thema's staat die afbakening er met de hand; deze toets bewaakt hem.
+  for (const m of T.listLayouts()) {
+    const css = fs.readFileSync(T.layoutFile(m.id, m.version, m.stylesheet), 'utf8')
+      .replace(/\/\*[\s\S]*?\*\//g, '')
+      // De kop van een @media-blok is geen selector; de regels erin wel.
+      .replace(/@[^{;]+\{/g, '');
+    const selectors = [...css.matchAll(/([^{}@;]+)\{/g)]
+      .map((x) => x[1].trim())
+      .filter((sel) => sel && !sel.startsWith('@') && !/^\(/.test(sel));
+    assert.ok(selectors.length > 0, `${m.id} heeft regels`);
+    for (const group of selectors) {
+      for (const sel of group.split(',').map((x) => x.trim()).filter(Boolean)) {
+        assert.ok(sel.startsWith(`html[data-layout="${m.id}"]`),
+          `${m.id}: "${sel}" staat niet onder html[data-layout="${m.id}"]`);
+      }
+    }
+  }
+});
+
+test('een manifest dat niet klopt wordt geweigerd, met een reden', () => {
+  const goed = { id: 'x', name: 'X', version: '1.0.0', contract: T.LAYOUT_CONTRACT, stylesheet: 'a.css' };
+  assert.ok(T.validateLayoutManifest(goed, 'x'));
+  assert.equal(T.validateLayoutManifest({ ...goed, id: '../x' }, '../x'), null, 'geen pad als id');
+  assert.equal(T.validateLayoutManifest({ ...goed, contract: 99 }, 'x'), null, 'ander contract valt terug');
+  assert.equal(T.validateLayoutManifest({ ...goed, version: 'latest' }, 'x'), null, 'versie is x.y.z');
+  assert.equal(T.validateLayoutManifest({ ...goed, stylesheet: '../../etc/passwd' }, 'x'), null, 'stylesheet is een bestandsnaam');
+  assert.equal(T.validateLayoutManifest(goed, 'y'), null, 'id hoort bij zijn map');
+});
+
+// ── Het serveren ──────────────────────────────────────────────────
+
+test('alleen wat het pakket noemt wordt geserveerd, en alleen van deze versie', () => {
+  const s = T.getLayout('sidebar');
+  assert.ok(T.layoutFile('sidebar', s.version, 'layout.css'));
+  assert.ok(T.layoutFile('sidebar', s.version, 'theme.json'));
+  assert.equal(T.layoutFile('sidebar', s.version, '../../config/database.js'), null);
+  assert.equal(T.layoutFile('sidebar', s.version, 'anders.css'), null);
+  assert.equal(T.layoutFile('sidebar', '0.0.1', 'layout.css'), null, 'een oude versie bestaat niet meer');
+  assert.equal(T.layoutFile('..', s.version, 'layout.css'), null);
+  assert.equal(T.layoutStylesheetUrl(s), `/themes/sidebar/${s.version}/layout.css`);
+});
+
+// ── Wie kiest ─────────────────────────────────────────────────────
+
+const metZijbalk = { slug: 'me', layout: 'sidebar' };
+const zonder = { slug: 'me', layout: null };
+
+test('de site kiest; zonder keuze is het de gewone kop', () => {
+  assert.equal(T.resolveLayout(metZijbalk).id, 'sidebar');
+  assert.equal(T.resolveLayout(zonder), null);
+  assert.equal(T.resolveLayout({ slug: 'me', layout: 'bestaat-niet' }), null, 'een onbekend thema valt terug');
+});
+
+test('een bezoeker kiest niet: ?layout= doet alleen iets voor wie de site beheert', () => {
+  assert.equal(T.resolveLayout(zonder, { preview: 'sidebar' }), null, 'bezoeker');
+  assert.equal(T.resolveLayout(zonder, { preview: 'sidebar', canManage: true }).id, 'sidebar', 'beheerder');
+  assert.equal(T.resolveLayout(metZijbalk, { preview: 'top', canManage: true }), null, 'beheerder kan de kop voorproeven');
+  assert.equal(T.resolveLayout(metZijbalk, { preview: 'top' }).id, 'sidebar', 'een bezoeker zet hem niet uit');
+});
+
+test('het beheer houdt altijd de gewone kop, ook met een thema gekozen', () => {
+  assert.equal(T.resolveLayout(metZijbalk, { isAdminPage: true }), null);
+  assert.equal(T.resolveLayout(zonder, { isAdminPage: true, canManage: true, preview: 'sidebar' }), null);
+});
+
+// ── Wat Shaer ervan te zien krijgt ────────────────────────────────
+
+test('de actor noemt het thema alleen als er een gekozen is', () => {
+  assert.deepEqual(T.layoutActorProp('https://test.example', zonder), {});
+  const p = T.layoutActorProp('https://test.example', metZijbalk)['shaer:theme'];
+  assert.equal(p.type, 'shaer:Theme');
+  assert.equal(p.id, `https://test.example/themes/sidebar/${T.getLayout('sidebar').version}/theme.json`);
+  assert.equal(p['shaer:contract'], T.LAYOUT_CONTRACT);
+});
