Index: src/middleware/auth.js
===================================================================
--- src/middleware/auth.js	(revision d5e78f7b26ebf7594ad7ca7887b0de9f1e40b47d)
+++ src/middleware/auth.js	(revision d7746798ec30f65765731ccbef3852624ec58f96)
@@ -38,8 +38,8 @@
 }
 
-// Een 'kijker' mag ALLES bekijken (incl. Beheer) maar NIETS wijzigen. De
-// schrijf-blokkade zit in de globale guard in server.js; deze helper bepaalt
-// alleen "is dit een alleen-lezen account?". `readonly` is de legacy-vlag die
-// we nog meenemen zodat niet-gemigreerde demo-accounts geblokkeerd blijven.
+// A 'kijker' (viewer) may VIEW everything (incl. Admin) but CHANGE nothing. The
+// write block lives in the global guard in server.js; this helper only determines
+// "is this a read-only account?". `readonly` is the legacy flag we still include
+// so unmigrated demo accounts remain blocked.
 export function isViewer(user) {
   return !!user && (user.role === 'kijker' || !!user.readonly);
@@ -49,6 +49,6 @@
   if (!req.session?.user) return loginRedirect(req, res);
   const role = req.session.user.role;
-  // god beheert; een kijker mág het Beheer-paneel zien (alleen-lezen) — de
-  // globale guard 403't elke write, dus dit geeft enkel kijk-toegang.
+  // god manages; a viewer MAY see the Admin panel (read-only) — the
+  // global guard 403s every write, so this only grants view access.
   if (role !== 'god' && role !== 'kijker') {
     return res.status(403).send('God role required');
@@ -57,23 +57,23 @@
 }
 
-// Mag de ingelogde user de HUIDIGE site (res.locals.site) beheren? god altijd;
-// anders alleen de owner van die site. Gebruikt voor site-gescopete beheerroutes
-// die een artiest via /user/<eigen-slug>/admin/... bereikt (res.locals.site is dan
-// z'n eigen site; een vreemde slug levert een andere site -> 403).
+// Can the logged-in user manage the CURRENT site (res.locals.site)? god always;
+// otherwise only the owner of that site. Used for site-scoped admin routes
+// that an artist reaches via /user/<own-slug>/admin/... (res.locals.site is then
+// their own site; a foreign slug yields a different site -> 403).
 export function requireSiteManager(req, res, next) {
   if (!req.session?.user) return loginRedirect(req, res);
   const u = req.session.user;
-  if (u.role === 'god' || u.role === 'kijker') return next(); // kijker = alleen-lezen kijk-toegang
+  if (u.role === 'god' || u.role === 'kijker') return next(); // viewer = read-only view access
   const site = res.locals.site;
-  // owner OF toegewezen mede-beheerder (site_members) — canAdminSite dekt beide.
+  // owner OR assigned co-admin (site_members) — canAdminSite covers both.
   if (site && PermissionsService.canAdminSite(u, site)) return next();
   return res.status(403).send('Geen toegang tot deze site.');
 }
 
-// Idem, maar de site wordt bepaald door de :slug-parameter (bv. site-edit).
+// Same, but the site is determined by the :slug parameter (e.g. site-edit).
 export function requireSiteManagerBySlug(req, res, next) {
   if (!req.session?.user) return loginRedirect(req, res);
   const u = req.session.user;
-  if (u.role === 'god' || u.role === 'kijker') return next(); // kijker = alleen-lezen kijk-toegang
+  if (u.role === 'god' || u.role === 'kijker') return next(); // viewer = read-only view access
   const site = db.prepare('SELECT id, owner_id FROM sites WHERE slug = ?').get(req.params.slug);
   if (site && PermissionsService.canAdminSite(u, site)) return next();
Index: src/middleware/rate-limit.js
===================================================================
--- src/middleware/rate-limit.js	(revision d5e78f7b26ebf7594ad7ca7887b0de9f1e40b47d)
+++ src/middleware/rate-limit.js	(revision d7746798ec30f65765731ccbef3852624ec58f96)
@@ -14,9 +14,9 @@
 import { renderPage } from './render.js';
 
-// Achter Cloudflare/Caddy kan req.ip binnenkomen als "1.2.3.4:11046" (IPv4 met
-// poort). express-rate-limit v7 valideert het IP en gooit anders
-// ERR_ERL_INVALID_IP_ADDRESS — onafgevangen async → het proces crasht (en pm2
-// loopt in een restart-loop). Strip een trailing IPv4-poort, val terug op de
-// socket, en laat IPv6 (meerdere dubbele punten) ongemoeid.
+// Behind Cloudflare/Caddy, req.ip can arrive as "1.2.3.4:11046" (IPv4 with
+// port). express-rate-limit v7 validates the IP and otherwise throws
+// ERR_ERL_INVALID_IP_ADDRESS — uncaught async → the process crashes (and pm2
+// enters a restart loop). Strip a trailing IPv4 port, fall back to the
+// socket address, and leave IPv6 (multiple colons) untouched.
 function clientKey(req) {
   let ip = req.ip || req.socket?.remoteAddress || '';
Index: src/middleware/render.js
===================================================================
--- src/middleware/render.js	(revision d5e78f7b26ebf7594ad7ca7887b0de9f1e40b47d)
+++ src/middleware/render.js	(revision d7746798ec30f65765731ccbef3852624ec58f96)
@@ -27,7 +27,7 @@
 const VIEWS_DIR = path.join(__dirname, '..', 'views');
 
-// App-versie (uit package.json) + korte commit-hash (uit .klonkt-version, door de
-// deploy geschreven) — getoond in de footer naast "Klonkt Beta". De hash loopt
-// automatisch mee bij élke deploy, dus de versie is nooit meer stale.
+// App version (from package.json) + short commit hash (from .klonkt-version, written by
+// the deploy script) — shown in the footer next to "Klonkt Beta". The hash is updated
+// automatically on every deploy, so the displayed version is never stale.
 let APP_VERSION = '';
 try {
@@ -36,10 +36,9 @@
     const sha = fs.readFileSync(path.join(__dirname, '..', '..', '.klonkt-version'), 'utf8').trim().slice(0, 7);
     if (sha) APP_VERSION += ' · ' + sha;
-  } catch { /* geen .klonkt-version (lokale dev) */ }
-} catch { /* geen versie beschikbaar */ }
-
-// Site-tijdzone (Beheer → Instellingen). Leeg = server-default (UTC). Wordt
-// toegepast op álle server-side geformatteerde datums zodat ze in de zone van
-// de site staan i.p.v. UTC.
+  } catch { /* no .klonkt-version (local dev) */ }
+} catch { /* no version available */ }
+
+// Site timezone (Admin → Settings). Empty = server default (UTC). Applied to
+// all server-side formatted dates so they display in the site's timezone instead of UTC.
 const siteTimezone = () => getSetting('timezone') || undefined;
 
@@ -58,17 +57,17 @@
   const isPartial = req.headers['hx-request'] === 'true' || req.query.partial === '1';
 
-  // Voorkom dat de browser een htmx-PARTIAL (alleen #pcms-main, zónder <head>/CSS)
-  // onder dezelfde URL cachet en bij "terug" als volledige pagina serveert →
-  // ongestylede HTML. Vary: HX-Request scheidt partial- en full-responses in de
-  // cache; no-store op de partial zelf laat "terug" altijd de volledige pagina
-  // opnieuw ophalen. (Vary geldt ook voor tussenliggende caches/Cloudflare.)
+  // Prevent the browser from caching an htmx PARTIAL (only #pcms-main, without <head>/CSS)
+  // under the same URL and serving it as a full page on "back" → unstyled HTML.
+  // Vary: HX-Request separates partial and full responses in the cache;
+  // no-store on the partial forces "back" to always re-fetch the full page.
+  // (Vary also applies to intermediate caches / Cloudflare.)
   res.setHeader('Vary', 'HX-Request');
   if (isPartial) res.setHeader('Cache-Control', 'no-store');
 
-  // Bezit deze (niet-god) user een eigen site? Bepaalt of 'ie een "Beheer"-
-  // ingang ziet (artiest-zelfbeheer). god ziet beheer sowieso (op rol).
+  // Does this (non-god) user own a site? Determines whether they see an "Admin"
+  // entry (artist self-manage). god always sees admin (by role).
   let _u = req.session?.user || null;
-  // Ververs avatar + rol uit de DB zodat een stale sessie (bv. na een
-  // avatar-wijziging of rolwissel) zichzelf herstelt zonder opnieuw inloggen.
+  // Refresh avatar + role from the DB so a stale session (e.g. after an
+  // avatar change or role switch) heals itself without a new login.
   if (_u && _u.id) {
     const _fresh = db.prepare('SELECT avatar_url, role, lang FROM users WHERE id = ?').get(_u.id);
@@ -78,6 +77,6 @@
     db.prepare('SELECT 1 FROM sites WHERE owner_id = ? LIMIT 1').get(_u.id));
 
-  // De avatar van de SITE-EIGENAAR (niet de kijker!) — voor de Klonkt-site-kop,
-  // zodat de artiest z'n eigen account-foto als sitefoto kan gebruiken.
+  // The avatar of the SITE OWNER (not the viewer!) — for the Klonkt site header,
+  // so the artist can use their own account photo as the site photo.
   const _site = data.site || res.locals.site || null;
   const siteOwnerAvatar = (_site && _site.owner_id)
@@ -85,17 +84,17 @@
     : null;
 
-  // Kijker-modus: alles bekijken mag, niets wijzigen. Views gebruiken canMutate
-  // om schrijf-knoppen (posten, opslaan, verwijderen) te verbergen/uit te zetten.
+  // Viewer mode: may view everything, change nothing. Views use canMutate
+  // to hide/disable write buttons (post, save, delete).
   const _isViewer = isViewer(_u);
 
-  // Wie ziet de "Beheer"-link? god/admin, een site-eigenaar (artiest-zelfbeheer),
-  // én een kijker (mag het Beheer alleen-lezen inzien). Eén bron van waarheid,
-  // gespiegeld in topnav/hub-nav/profielsheet — anders raakt de link verborgen
-  // voor wie 'm wél mag zien (kijker zag 'm eerst nergens).
+  // Who sees the "Admin" link? god/admin, a site owner (artist self-manage),
+  // and a viewer (may view Admin read-only). One source of truth,
+  // mirrored in topnav/hub-nav/profile sheet — otherwise the link gets hidden
+  // for those who should see it (viewer didn't see it anywhere before).
   const _role = _u ? _u.role : null;
   const canSeeBeheer = !!(_u && (_role === 'god' || _role === 'admin' || _role === 'kijker' || userOwnsSite));
 
-  // Interface-taal: sessie-keuze (deze sessie) → eigen voorkeur van de ingelogde
-  // gebruiker (users.lang) → admin-ingestelde standaard (Beheer) → env → browser → nl.
+  // Interface language: session choice (this session) → logged-in user's own preference
+  // (users.lang) → admin-set default (Admin → Settings) → env → browser → nl.
   const _lang = resolveLang(req, {
     userLang: _u && _u.lang,
@@ -125,6 +124,6 @@
     tenancy: res.locals.tenancy || 'solo',
     hubTitle: getSetting('hub_title') || '',
-    footerNewsletter: getSetting('footer_newsletter') === '1', // nieuwsbrief-aanmelding in footer (premium)
-    agendaEnabled: getSetting('agenda_enabled') === '1', // Agenda/evenementen tonen in de pill (premium, opt-in)
+    footerNewsletter: getSetting('footer_newsletter') === '1', // newsletter sign-up in footer (premium)
+    agendaEnabled: getSetting('agenda_enabled') === '1', // show agenda/events in the pill (premium, opt-in)
     platforms_catalog: PLATFORMS_CATALOG,
     permissions: PermissionsService,
@@ -153,8 +152,8 @@
       // looks "unclickable". Escape any non-ASCII to \uXXXX: the header stays
       // ASCII-safe and remains valid JSON that htmx parses back unchanged.
-      // Per-site accent + palette zitten in de shell-<head> (style#pcms-site-accent
-      // + html[data-palette]) en worden NIET mee-geswapt bij htmx-nav. Stuur ze mee
-      // zodat de client ze bijwerkt — anders erft een artiest de kleuren van de
-      // vorige pagina (bv. hub-paars i.p.v. eigen groen). Zelfde afleiding als shell.ejs.
+      // Per-site accent + palette live in the shell <head> (style#pcms-site-accent
+      // + html[data-palette]) and are NOT swapped during htmx navigation. Send them along
+      // so the client updates them — otherwise an artist inherits the previous page's
+      // colours (e.g. hub-purple instead of their own green). Same derivation as shell.ejs.
       const _navAccent = (_site && _site.accent && /^#[0-9a-fA-F]{6}$/.test(_site.accent))
         ? _site.accent : '#e8b04b';
@@ -169,9 +168,9 @@
       }).replace(/[-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
       res.setHeader('HX-Trigger-After-Settle', triggerJson);
-      // Site-chrome out-of-band mee-renderen, zodat de kop (topnav/profielkop/
-      // view-switcher) bij navigatie ALTIJD bij de nieuwe pagina/artiest hoort —
-      // terwijl de audioplayer (los in document.body) blijft leven (geen
-      // verspringen). htmx vervangt #pcms-chrome via hx-swap-oob. Niet kritisch:
-      // faalt 't, dan blijft de oude chrome staan (geen crash).
+      // Render the site chrome out-of-band so the header (topnav/profile header/
+      // view-switcher) ALWAYS matches the new page/artist on navigation —
+      // while the audio player (separate in document.body) keeps playing (no
+      // interruption). htmx replaces #pcms-chrome via hx-swap-oob. Non-critical:
+      // if it fails, the old chrome remains (no crash).
       let oobChrome = '';
       try {
@@ -181,5 +180,5 @@
           { async: false },
         );
-      } catch (e) { /* chrome-OOB overslaan */ }
+      } catch (e) { /* skip chrome OOB */ }
       return res.send(pageContent + oobChrome);
     }
Index: src/middleware/site.js
===================================================================
--- src/middleware/site.js	(revision d5e78f7b26ebf7594ad7ca7887b0de9f1e40b47d)
+++ src/middleware/site.js	(revision d7746798ec30f65765731ccbef3852624ec58f96)
@@ -15,8 +15,8 @@
 
 /**
- * De primaire/hoofd-site — ÉÉN bron van waarheid (vervangt de "oudste site ="
- * hoofd"-aanname die voorheen los in resolveSite/hub/account/admin stond).
- * Leest de expliciete is_primary-vlag; valt terug op de oudste als die (nog)
- * nergens staat, zodat bestaand gedrag exact behouden blijft.
+ * The primary/main site — ONE source of truth (replaces the "oldest site ="
+ * main" assumption that was previously scattered across resolveSite/hub/account/admin).
+ * Reads the explicit is_primary flag; falls back to the oldest if it isn't set
+ * anywhere yet, so existing behaviour is preserved exactly.
  */
 export function getPrimarySite() {
@@ -28,12 +28,12 @@
 export function resolveSite(req, res, next) {
   const tenancy = getTenancy();
-  res.locals.tenancy = tenancy; // ook beschikbaar voor views
+  res.locals.tenancy = tenancy; // also available in views
 
-  // In HUB-mode mapt /user/:slug naar een specifieke site. In SOLO-mode bestaat
-  // er maar één site: we slaan die routing over en pinnen op de primaire site.
+  // In HUB mode /user/:slug maps to a specific site. In SOLO mode there is only
+  // one site: we skip that routing and pin to the primary site.
   if (tenancy === 'hub') {
-    // Een Klonkt-site is canoniek bereikbaar via /user/:slug. /sites/:slug is een
-    // legacy-alias → 301 naar de canonieke vorm zodat er één URL-schema overblijft
-    // (behoudt pad + querystring; raakt /admin/sites NIET, dat begint met /admin/).
+    // A Klonkt site is canonically reachable via /user/:slug. /sites/:slug is a
+    // legacy alias → 301 to the canonical form so one URL scheme remains
+    // (preserves path + query string; does NOT touch /admin/sites, which starts with /admin/).
     const m = req.path.match(/^\/(sites|user)\/([a-zA-Z0-9_-]+)(\/.*)?$/);
     if (m) {
@@ -49,10 +49,10 @@
       }
     }
-    // (Verwijderd: een dode "slug == hostname"-subdomein-hack. Slugs mogen geen
-    // punten bevatten, dus die kon nooit matchen. Echte subdomein-routing zou de
-    // subdomein-LABEL tegen de slug matchen — een aparte feature, niet dit.)
+    // (Removed: a dead "slug == hostname" subdomain hack. Slugs may not contain
+    // dots, so it could never match. Real subdomain routing would match the
+    // subdomain LABEL against the slug — a separate feature, not this.)
   }
 
-  // Solo (of hub zonder match): pin op de primaire/hoofd-site.
+  // Solo (or hub without a match): pin to the primary/main site.
   const defaultSite = getPrimarySite();
   if (defaultSite) {
@@ -79,7 +79,7 @@
 
   try {
-    // m.filename = de kale bestandsnaam; de speelbare URL is de gated stream-route
-    // (audioUrl). De media-tabel heeft GEEN url-kolom — de oude query selecteerde
-    // m.url en faalde dus altijd stil (lege speler). Nu bouwen we de URL uit filename.
+    // m.filename = the bare filename; the playable URL is the gated stream route
+    // (audioUrl). The media table has NO url column — the old query selected
+    // m.url and always failed silently (empty player). Now we build the URL from filename.
     const rows = db.prepare(`
       SELECT t.id, t.title, t.artist, t.duration, t.position, m.filename
