Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 5d0d3f352a5ea5b25d79dc7d7c29ffe9cf6c0d40)
+++ src/config/database.js	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
@@ -249,4 +249,7 @@
   // elke byte van de canonieke vorm, dus herschrijven zou hem breken.
   ensureColumn('sites', 'identity_proofs', 'TEXT');
+  // De lay-out die de beheerder koos (experimental/theme): het id van een
+  // thema in src/themes/, of leeg voor de gewone kop. Geen bezoekerskeuze.
+  ensureColumn('sites', 'layout', 'TEXT');
   ensureColumn('sites', 'show_search',     'INTEGER DEFAULT 1');
   ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1');
Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision 5d0d3f352a5ea5b25d79dc7d7c29ffe9cf6c0d40)
+++ src/middleware/render.js	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
@@ -21,4 +21,5 @@
 import { emojiHtml, emojiName, parseQuote } from '../services/NoteRender.js';
 import ActivityPubService from '../services/ActivityPubService.js';
+import ThemeService from '../services/ThemeService.js';
 import { imgProxyUrl } from '../services/ThumbnailService.js';
 import { audioEnabled as audioFeatureEnabled } from '../config/features.js';
@@ -187,4 +188,16 @@
     defaultLang: getSetting('default_lang'),
   });
+
+  // Lay-out (experimental/theme): wie de site beheert, mag met ?layout= een
+  // ander thema voorproeven; een bezoeker kiest niets.
+  const _canManageSite = !!(_u && (_role === 'god' || _role === 'admin' || userOwnsSite));
+  const _isAdminPage = String(viewName || '').startsWith('pages/admin')
+    || String(data.bodyClass || '').split(/\s+/).includes('on-admin');
+  const _preview = typeof req.query.layout === 'string' ? req.query.layout : null;
+  const _layout = ThemeService.resolveLayout(_site, { isAdminPage: _isAdminPage, canManage: _canManageSite, preview: _preview });
+  const _siteLayout = _site && _site.layout ? ThemeService.getLayout(_site.layout) : null;
+  const _previewLayout = (_canManageSite && _preview) ? ThemeService.getLayout(_preview) : null;
+  const _layoutSheets = [...new Set([_siteLayout, _previewLayout].filter(Boolean)
+    .map((m) => ThemeService.layoutStylesheetUrl(m)))];
 
   // Common locals
@@ -252,4 +265,13 @@
     appVersion: APP_VERSION,
     bodyClass: data.bodyClass || 'on-home',
+    // De lay-out van deze pagina (experimental/theme). null = de gewone kop.
+    // Beheerpagina's altijd de gewone kop; ?layout= alleen voor wie de site
+    // beheert (zie ThemeService.resolveLayout).
+    layoutTheme: _layout ? { id: _layout.id, name: _layout.name } : null,
+    // De stylesheets die mee moeten: die van de site en, bij een voorproefje,
+    // die van het voorproefje. Ze staan er ook op een pagina die zelf geen
+    // lay-out draagt (beheer): hun regels gelden alleen onder data-layout, en
+    // wie vanuit het beheer naar de site navigeert heeft ze dan al.
+    layoutSheets: _layoutSheets,
     // Welke module(s) deze pagina nodig heeft (shaer-bqr). De shell zet ze op
     // body[data-js]; de bootstrap daar importeert ze uit /assets/js/mod/.
@@ -299,5 +321,5 @@
       const _navJs = ('chrome ' + (locals.pageJs || '')).trim();
       const triggerJson = JSON.stringify({
-        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs },
+        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs, layout: locals.layoutTheme ? locals.layoutTheme.id : 'top' },
         pcmsPostSwap: data.post ? {
           title: data.post.title,
Index: src/routes/admin-sites.js
===================================================================
--- src/routes/admin-sites.js	(revision 5d0d3f352a5ea5b25d79dc7d7c29ffe9cf6c0d40)
+++ src/routes/admin-sites.js	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
@@ -191,4 +191,5 @@
     users: listOwnerCandidates(),
     palettes: ThemeService.listPalettes(),
+    layouts: ThemeService.listLayouts(),
     accents: ThemeService.listAccents(),
     platforms: listPlatforms(),
@@ -268,4 +269,5 @@
     users: listOwnerCandidates(),
     palettes: ThemeService.listPalettes(),
+    layouts: ThemeService.listLayouts(),
     accents: ThemeService.listAccents(),
     platforms: listPlatforms(),
@@ -334,4 +336,8 @@
   // "Auto" — defer to user's prefers-color-scheme on first paint.
   const themeOverride = ['light', 'dark'].includes(f.theme_override) ? f.theme_override : '';
+  // layout: alleen een thema dat deze Klonkt kent (experimental/theme). Leeg
+  // is de gewone kop. Een onbekend id opslaan zou een site maken die naar
+  // niets verwijst, en die valt dan stil terug -- liever meteen leeg.
+  const layout = ThemeService.getLayout(f.layout) ? f.layout : null;
 
   // accent: only accept colors from the curated ACCENTS list. Falls back to
@@ -342,5 +348,5 @@
     UPDATE sites SET
       title = ?, description = ?, tagline = ?, language = ?,
-      palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
+      palette = ?, accent = ?, theme_override = ?, layout = ?, profile_photo = ?,
       profile_enabled = ?,
       ap_aliases = ?,
@@ -361,4 +367,5 @@
     accent,
     themeOverride,
+    layout,
     f.profile_photo || null,
     f.profile_enabled ? 1 : 0,
Index: src/server.js
===================================================================
--- src/server.js	(revision 5d0d3f352a5ea5b25d79dc7d7c29ffe9cf6c0d40)
+++ src/server.js	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
@@ -17,4 +17,5 @@
 import http from 'http';
 import db, { initializeDatabase } from './config/database.js';
+import ThemeService from './services/ThemeService.js';
 import { startScheduler } from './services/Scheduler.js';
 import { SqliteSessionStore } from './services/SqliteSessionStore.js';
@@ -238,4 +239,15 @@
 
 app.use('/assets', express.static(path.join(__dirname, 'assets'), { maxAge: isDev ? 0 : '1y' }));
+// Lay-outthema's (experimental/theme). De versie staat in het pad, dus een
+// nieuwe versie is een nieuwe URL en deze cache van een jaar klopt altijd --
+// geen ?v= om bij te houden. Alleen wat het pakket zelf noemt wordt geserveerd
+// (ThemeService.layoutFile); een pad uit de URL komt nergens in een join.
+app.get('/themes/:id/:version/:file', (req, res) => {
+  const file = ThemeService.layoutFile(req.params.id, req.params.version, req.params.file);
+  if (!file) return res.status(404).end();
+  res.set('Cache-Control', isDev ? 'no-cache' : 'public, max-age=31536000, immutable');
+  res.set('X-Content-Type-Options', 'nosniff');
+  res.sendFile(file);
+});
 
 // On-demand cover thumbnails: /media/thumb/<w>/<path> → a small lanczos-downscaled WebP
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 5d0d3f352a5ea5b25d79dc7d7c29ffe9cf6c0d40)
+++ src/services/ActivityPubService.js	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
@@ -20,4 +20,5 @@
 import path from 'path';
 import db, { NU_ISO, isoSql } from '../config/database.js';
+import ThemeService from './ThemeService.js';
 import HtmlSanitizerService from './HtmlSanitizerService.js';
 import AudioEmbedService from './AudioEmbedService.js';
@@ -385,4 +386,9 @@
     // (guardianship module owns these).
     ...Guardianship.guardianshipActorProps(id, site.slug),
+    // Het thema dat de beheerder koos (experimental/theme). Shaer haalt het hier
+    // op in plaats van er zelf een te kiezen: één keuze kleedt de site en de app.
+    // Alleen als er een gekozen is; zonder staat er niets en houdt elke client
+    // zijn eigen standaard.
+    ...ThemeService.layoutActorProp(base, site),
     // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths.
     // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration
Index: src/services/ThemeService.js
===================================================================
--- src/services/ThemeService.js	(revision 5d0d3f352a5ea5b25d79dc7d7c29ffe9cf6c0d40)
+++ src/services/ThemeService.js	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
@@ -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,
+      },
+    };
+  }
 }
 
Index: src/services/i18n.js
===================================================================
--- src/services/i18n.js	(revision 5d0d3f352a5ea5b25d79dc7d7c29ffe9cf6c0d40)
+++ src/services/i18n.js	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
@@ -289,4 +289,7 @@
     'asite.theme_light': 'Licht',
     'asite.theme_dark': 'Donker',
+    'asite.layout': 'Lay-out',
+    'asite.layout_top': 'Gewone kop',
+    'asite.layout_hint': 'Hoe de site eruitziet op een breed scherm. Bezoekers kiezen niet; deze keuze geldt voor iedereen. Het beheer houdt altijd de gewone kop.',
     'asite.palette': 'Palet',
     'asite.behavior': 'Gedrag',
@@ -1299,4 +1302,7 @@
     'asite.theme_light': 'Light',
     'asite.theme_dark': 'Dark',
+    'asite.layout': 'Layout',
+    'asite.layout_top': 'Top bar',
+    'asite.layout_hint': 'How the site looks on a wide screen. Visitors do not choose; this applies to everyone. The admin always keeps the top bar.',
     'asite.palette': 'Palette',
     'asite.behavior': 'Behavior',
@@ -2308,4 +2314,7 @@
     'asite.theme_light': 'Hell',
     'asite.theme_dark': 'Dunkel',
+    'asite.layout': 'Layout',
+    'asite.layout_top': 'Kopfleiste',
+    'asite.layout_hint': 'Wie die Seite auf einem breiten Bildschirm aussieht. Besucher wählen nicht; dies gilt für alle. Die Verwaltung behält immer die Kopfleiste.',
     'asite.palette': 'Palette',
     'asite.behavior': 'Verhalten',
Index: src/themes/sidebar/layout.css
===================================================================
--- src/themes/sidebar/layout.css	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
+++ src/themes/sidebar/layout.css	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
@@ -0,0 +1,198 @@
+/*
+ * Lay-out "zijbalk" -- het eerste ingebouwde thema (experimental/theme, op weg
+ * naar 2.0). Een thema is een pakket: theme.json ernaast, dit bestand erin.
+ *
+ * DEZELFDE MARKUP, EEN ANDER STELSEL. Dit thema verandert geen template; het
+ * ordent wat er al staat. Dat is het contract (`"contract": 1` in theme.json):
+ * de ids en klassen waar de JS en de custom_css van beheerders op leunen,
+ * blijven waar ze zijn, en een thema mag alleen daarop bouwen.
+ *
+ * ELKE REGEL ONDER html[data-layout="sidebar"]. De shell zet dat attribuut
+ * alleen als dit het gekozen thema is, dus zolang hij niet gekozen is raakt
+ * dit bestand niets. Voor ingebouwde thema's staat die afbakening er met de
+ * hand; een thema van buiten krijgt hem straks bij installatie opgelegd.
+ *
+ * WIE KIEST: de beheerder van de site, in het beheer. Een bezoeker niet.
+ *
+ * ALLEEN OP EEN BREED SCHERM. Onder de 1024px blijft alles zoals het was: daar
+ * is de onderste tabbalk de navigatie, en een zijbalk zou de inhoud opeten.
+ */
+@media (min-width: 1024px) {
+
+  html[data-layout="sidebar"] { --sidebar-w: 15rem; }
+
+  /* Alleen ruimte maken als er ook een zijbalk IS. De hub-landing en de
+     in- en uitlogschermen hebben geen kop; die houden de volle breedte. */
+  html[data-layout="sidebar"] body:has(#pcms-chrome .masthead) {
+    padding-left: var(--sidebar-w);
+  }
+
+  /* ── De chrome wordt de zijbalk ─────────────────────────────── */
+  /* Niet de masthead maar het hele #pcms-chrome-slot: daar zitten de
+     masthead, de identiteit van de site (profile-header) en de Lezen/Grid-
+     wissel al samen in, en het slot wordt bij elke htmx-navigatie als
+     geheel ververst. Zo blijft alles bij elkaar dat bij de site hoort, en
+     bepaalt `order` de volgorde -- zonder dat er een regel markup verschuift. */
+  html[data-layout="sidebar"] #pcms-chrome:has(.masthead) {
+    position: fixed;
+    top: 0; bottom: 0; left: 0;
+    width: var(--sidebar-w);
+    display: flex;
+    flex-direction: column;
+    padding-top: var(--ios-safe-top, 0px);
+    border-right: 1px solid var(--rule);
+    background: var(--paper);
+    z-index: 30;
+    /* GEEN overflow hier: de taal- en gebruikersmenu's klappen naar rechts
+       uit de balk, en een scrollende balk zou ze afknippen. */
+  }
+
+  /* ── 1. Bovenaan: wie dit is ─────────────────────────────────── */
+  /* De specificiteit is opzet: de compacte stand op post-, zoek- en
+     archiefpagina's (foto 40px, geen bio) bestaat om ruimte boven de inhoud
+     te sparen. In een kolom is die ruimte er wel, en een identiteit die per
+     pagina van maat verspringt leest als een andere site. */
+  html[data-layout="sidebar"] #pcms-chrome .profile-header {
+    order: -2;
+    padding: 1.75rem 0 1.25rem;
+    border-bottom: 1px solid var(--rule);
+    background: transparent;
+  }
+  html[data-layout="sidebar"] #pcms-chrome .profile-header-inner {
+    max-width: none;
+    margin: 0;
+    padding: 0 1.6rem;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: .85rem;
+  }
+  html[data-layout="sidebar"] #pcms-chrome .profile-photo {
+    width: 72px;
+    height: 72px;
+    border-width: 2px;
+  }
+  html[data-layout="sidebar"] #pcms-chrome .profile-photo-fallback { font-size: 1.75rem; }
+  html[data-layout="sidebar"] #pcms-chrome .profile-name {
+    font-size: 1.4rem;
+    margin: 0 0 .3rem;
+    overflow-wrap: anywhere;
+  }
+  html[data-layout="sidebar"] #pcms-chrome .profile-bio {
+    display: block;
+    font-size: .85rem;
+  }
+  html[data-layout="sidebar"] #pcms-chrome .profile-links { flex-wrap: wrap; }
+
+  /* ── 2. Hoe je de site leest ─────────────────────────────────── */
+  /* De Lezen/Grid-wissel zit in hetzelfde slot, dus hij komt mee: onder de
+     identiteit, als keuze die bij de site hoort in plaats van boven de stroom. */
+  html[data-layout="sidebar"] #pcms-chrome .view-switcher-wrap {
+    order: -1;
+    margin: 0;
+    padding: .9rem 0;
+    border-bottom: 1px solid var(--rule);
+    background: transparent;
+  }
+  html[data-layout="sidebar"] #pcms-chrome .view-switcher-wrap .container {
+    max-width: none;
+    padding-inline: 1.6rem;
+    justify-content: flex-start;
+  }
+
+  /* ── 3. De navigatie vult de rest ────────────────────────────── */
+  html[data-layout="sidebar"] #pcms-chrome .masthead {
+    position: static;
+    flex: 1 1 auto;
+    display: flex;
+    flex-direction: column;
+    padding: 1rem 0 1.25rem;
+    border: 0;
+    background: transparent;
+  }
+
+  html[data-layout="sidebar"] .masthead-inner {
+    flex: 1 1 auto;
+    max-width: none;
+    margin: 0;
+    padding-inline: .75rem;
+    flex-direction: column;
+    align-items: stretch;
+    justify-content: flex-start;
+    gap: 1rem;
+  }
+
+  /* Het merk is overal verborgen (de profielkop draagt het), en dan is het
+     een leeg vak met een gat eronder. Behalve op beheerpagina's: daar staat
+     "terug naar de site", en dat is daar de enige weg terug. */
+  html[data-layout="sidebar"] .masthead-brand:not(:has(.nav-link-admin)) {
+    display: none;
+  }
+  html[data-layout="sidebar"] .masthead-brand {
+    flex: 0 0 auto;
+    overflow: visible;
+  }
+  html[data-layout="sidebar"] .masthead-brand .nav-link-admin {
+    width: 100%;
+  }
+
+  /* ── De navigatie staat onder elkaar ──────────────────────────── */
+  html[data-layout="sidebar"] .top-nav-desktop {
+    flex: 1 1 auto;
+    flex-direction: column;
+    align-items: stretch;
+    gap: .15rem;
+  }
+
+  html[data-layout="sidebar"] .top-nav-desktop > .nav-link,
+  html[data-layout="sidebar"] .top-nav-desktop > .nav-btn,
+  html[data-layout="sidebar"] .top-nav-desktop > details > summary {
+    width: 100%;
+    justify-content: flex-start;
+    padding: .55rem .85rem;
+  }
+  html[data-layout="sidebar"] .top-nav-desktop .nav-label { display: inline; }
+
+  /* Knoppen die in de kop alleen een pictogram zijn, krijgen hier hun naam.
+     De naam stond al in de markup, als title of aria-label; er is niets bij
+     gekomen. In een kolom onder elkaar leest een rij losse pictogrammen als
+     een raadsel. */
+  html[data-layout="sidebar"] .top-nav-desktop > .nav-btn[title]::after,
+  html[data-layout="sidebar"] .top-nav-desktop .lang-toggle[title]::after {
+    content: attr(title);
+  }
+  html[data-layout="sidebar"] .top-nav-desktop .user-toggle[aria-label]::after {
+    content: attr(aria-label);
+  }
+  /* Het pijltje hoort bij een menu dat naar beneden valt; hier klapt hij
+     opzij, en tussen de taalcode en de naam las het als "EN ⌄ Language". */
+  html[data-layout="sidebar"] .top-nav-desktop .lang-caret { display: none; }
+
+  /* Bovenin wat je zoekt (archief, zoeken); onderin wat bij JOU hoort
+     (thema, taal, meldingen, je account). Het thema is de eerste van dat
+     tweede rijtje, dus die duwt de rest naar beneden. */
+  html[data-layout="sidebar"] .top-nav-desktop > #theme-toggle {
+    margin-top: auto;
+  }
+
+  /* ── De uitklapmenu's klappen naar rechts open ───────────────── */
+  /* In de kop vielen ze naar beneden, rechts uitgelijnd. Onderin een smalle
+     balk zou dat buiten beeld vallen, dus: naast de knop, naar boven toe. */
+  html[data-layout="sidebar"] .user-dropdown,
+  html[data-layout="sidebar"] .lang-dropdown {
+    top: auto;
+    right: auto;
+    bottom: 0;
+    left: calc(100% + .6rem);
+    transform-origin: bottom left;
+  }
+  html[data-layout="sidebar"] .user-menu,
+  html[data-layout="sidebar"] .lang-menu {
+    position: relative;
+  }
+
+  /* ── Wat over de volle breedte vastzat, begint nu na de balk ──── */
+  html[data-layout="sidebar"] body:has(#pcms-chrome .masthead) .audio-player,
+  html[data-layout="sidebar"] body:has(#pcms-chrome .masthead) .audio-sheet {
+    left: var(--sidebar-w);
+  }
+}
Index: src/themes/sidebar/theme.json
===================================================================
--- src/themes/sidebar/theme.json	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
+++ src/themes/sidebar/theme.json	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
@@ -0,0 +1,10 @@
+{
+  "id": "sidebar",
+  "name": "Zijbalk",
+  "description": "De identiteit van de site en de navigatie in een vaste kolom links; de inhoud ernaast. Alleen op een breed scherm -- smaller blijft het de gewone kop met de tabbalk.",
+  "version": "1.0.0",
+  "contract": 1,
+  "stylesheet": "layout.css",
+  "author": "Klonkt",
+  "license": "AGPL-3.0-or-later"
+}
Index: src/views/pages/admin-site-edit.ejs
===================================================================
--- src/views/pages/admin-site-edit.ejs	(revision 5d0d3f352a5ea5b25d79dc7d7c29ffe9cf6c0d40)
+++ src/views/pages/admin-site-edit.ejs	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
@@ -103,4 +103,27 @@
       </fieldset>
 
+      <%# ── De lay-out (experimental/theme) ──────────────────────────────
+          Een thema uit src/themes/. Alleen de beheerder kiest; een bezoeker
+          niet. Het beheer zelf houdt altijd de gewone kop, zodat een kapot
+          thema je nooit buitensluit van deze instelling. %>
+      <% if (typeof layouts !== 'undefined' && layouts && layouts.length) { %>
+      <fieldset class="layout-choice">
+        <legend><%= t('asite.layout') %></legend>
+        <% const lc = site.layout || ''; %>
+        <label class="cb">
+          <input type="radio" name="layout" value="" <%= lc === '' ? 'checked' : '' %>>
+          <span><%= t('asite.layout_top') %></span>
+        </label>
+        <% layouts.forEach(function (l) { %>
+        <label class="cb">
+          <input type="radio" name="layout" value="<%= l.id %>" <%= lc === l.id ? 'checked' : '' %>>
+          <span><%= l.name %></span>
+          <% if (site.slug) { %><a class="layout-preview" href="/?layout=<%= encodeURIComponent(l.id) %>" target="_blank" rel="noopener">voorproefje</a><% } %>
+        </label>
+        <% }); %>
+        <p class="hint"><%= t('asite.layout_hint') %></p>
+      </fieldset>
+      <% } %>
+
       <fieldset class="palette-grid">
         <legend><%= t('asite.palette') %></legend>
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 5d0d3f352a5ea5b25d79dc7d7c29ffe9cf6c0d40)
+++ src/views/shell.ejs	(revision 495b0191f40d1e9e9de36d8ce98cad627ffb13cb)
@@ -132,5 +132,5 @@
 const MOD_V = 68;
 %><!DOCTYPE html>
-<html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>">
+<html lang="<%- _e(lang) %>" data-palette="<%- _e((typeof palette !== 'undefined' && palette) ? palette : (safeSite.palette || 'klonkt')) %>" data-layout="<%- _e((typeof layoutTheme !== 'undefined' && layoutTheme) ? layoutTheme.id : 'top') %>">
 <head>
 <meta charset="utf-8">
@@ -234,4 +234,11 @@
 <!-- v9 stylesheet (full palette system) -->
 <link rel="stylesheet" href="/assets/css/style.css?v=111">
+<%# De lay-outthema's (experimental/theme): de stylesheet van het thema dat de
+    site koos, plus die van een voorproefje voor wie de site beheert. Hun regels
+    gelden alleen onder <html data-layout="...">, dat de server hierboven al zet
+    -- dus geen script, geen flits, en geen bezoeker die iets anders kiest. %>
+<% if (typeof layoutSheets !== 'undefined' && layoutSheets) { layoutSheets.forEach(function (href) { %>
+<link rel="stylesheet" href="<%= href %>">
+<% }); } %>
 <script>
 /* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
@@ -485,4 +492,11 @@
     if (d.palette && /^[a-z0-9-]+$/i.test(d.palette)) {
       document.documentElement.setAttribute('data-palette', d.palette);
+    }
+    // De lay-out van de NIEUWE pagina (experimental/theme). De <html> wordt bij
+    // htmx-navigatie niet opnieuw gerenderd, dus zonder dit hield het beheer de
+    // zijbalk van de pagina ervoor -- terwijl beheer altijd de gewone kop hoort
+    // te hebben. Zelfde vorm als een thema-id, dus niets anders kan erin.
+    if (typeof d.layout === 'string' && /^[a-z0-9][a-z0-9.-]{0,63}$/.test(d.layout)) {
+      document.documentElement.setAttribute('data-layout', d.layout);
     }
 
