Index: .well-known/assetlinks.json
===================================================================
--- .well-known/assetlinks.json	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
+++ .well-known/assetlinks.json	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -0,0 +1,12 @@
+[
+    {
+        "relation": ["delegate_permission/common.handle_all_urls"],
+        "target": {
+            "namespace": "android_app",
+            "package_name": "com.roboburr.app",
+            "sha256_cert_fingerprints": [
+                "92:2F:55:C4:36:4C:8D:DA:99:0C:63:18:BA:78:A3:76:AD:B9:AD:77:E5:AA:68:54:16:FE:FB:28:62:11:EB:DB"
+            ]
+        }
+    }
+]
Index: deploy/DEPLOY.md
===================================================================
--- deploy/DEPLOY.md	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
+++ deploy/DEPLOY.md	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -0,0 +1,224 @@
+# PrutCMS v10 — Deployment Guide
+
+End-to-end production install on a fresh Ubuntu 22.04 / Debian 12 VPS
+(TransIP, Hetzner, etc.). Assumes a non-root user with sudo.
+
+---
+
+## 0. Prerequisites
+
+- VPS with Ubuntu 22.04 LTS or Debian 12.
+- DNS A/AAAA records for your domain pointing at the server's IP. Wait for
+  propagation (`dig +short YOUR-DOMAIN` should return the right IP) before
+  running certbot.
+- SSH access as a non-root user (e.g. `robin`).
+
+---
+
+## 1. System packages
+
+```bash
+sudo apt update && sudo apt upgrade -y
+sudo apt install -y curl ca-certificates git nginx ufw sqlite3
+```
+
+Install Node.js 20 LTS via NodeSource (don't use the OS-default — it's old):
+
+```bash
+curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
+sudo apt install -y nodejs
+node --version    # should print v20.x.x
+```
+
+Install PM2 globally:
+
+```bash
+sudo npm install -g pm2
+```
+
+---
+
+## 2. Firewall
+
+```bash
+sudo ufw allow OpenSSH
+sudo ufw allow 'Nginx Full'   # opens 80 + 443
+sudo ufw enable
+```
+
+---
+
+## 3. App user + project clone
+
+```bash
+# As root or via sudo, create a deploy user if you don't already have one
+# (skip if you're already on a non-root user)
+
+# As your user:
+mkdir -p ~/prutcms
+cd ~/prutcms
+# Clone or rsync your code here. e.g. via git:
+# git clone <your-repo> .
+
+npm ci --omit=dev
+```
+
+`ensureLocalHtmx()` in `server.js` will copy the bundled HTMX from
+`node_modules/htmx.org/dist/htmx.min.js` to `src/assets/js/htmx.min.js` on
+first boot. You don't have to do that step manually.
+
+---
+
+## 4. Environment variables
+
+Create `.env` in the project root:
+
+```ini
+NODE_ENV=production
+PORT=3000
+
+# 32+ random hex chars — required, the app refuses to boot without it.
+# Generate: openssl rand -hex 32
+SESSION_SECRET=<paste-strong-random-string>
+
+# Optional: pin the audio HMAC secret instead of letting the app generate one
+# in storage/.audio-secret. Generate the same way as SESSION_SECRET.
+# AUDIO_SECRET=<paste-different-random-string>
+
+# Optional: override storage paths
+# DATABASE_PATH=/home/robin/prutcms/storage/database.sqlite
+# AUDIO_PATH=/home/robin/prutcms/storage/audio
+# AVATAR_PATH=/home/robin/prutcms/storage/media/avatars
+# COVER_PATH=/home/robin/prutcms/storage/media/audio-covers
+# MEDIA_PATH=/home/robin/prutcms/storage/media
+```
+
+`chmod 600 .env` — keep it readable only by your user.
+
+---
+
+## 5. First boot
+
+```bash
+npm run migrate    # creates storage/database.sqlite + tables
+```
+
+Then start with PM2:
+
+```bash
+pm2 start ecosystem.config.cjs --env production
+pm2 save
+pm2 startup        # follow the printed command to make PM2 survive reboots
+```
+
+Check it's up:
+
+```bash
+curl -I http://127.0.0.1:3000/   # expect 200 / 302
+pm2 logs prutcms                 # live logs; Ctrl-C to detach
+```
+
+Register your first user (becomes god) by visiting
+`http://YOUR-SERVER-IP:3000/auth/register` BEFORE you point nginx at it
+(or just wait until SSL is up — registration works the same).
+
+---
+
+## 6. nginx + SSL
+
+```bash
+sudo cp deploy/nginx.conf.example /etc/nginx/sites-available/prutcms
+sudo $EDITOR /etc/nginx/sites-available/prutcms     # replace <YOUR-DOMAIN>
+sudo ln -s /etc/nginx/sites-available/prutcms /etc/nginx/sites-enabled/
+sudo rm -f /etc/nginx/sites-enabled/default
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+Install certbot and provision certs:
+
+```bash
+sudo apt install -y certbot python3-certbot-nginx
+sudo mkdir -p /var/www/letsencrypt
+sudo certbot --nginx -d YOUR-DOMAIN -d www.YOUR-DOMAIN
+```
+
+Certbot edits the nginx config in place to wire in the certificate paths.
+Renewals run automatically via the certbot systemd timer; verify with:
+
+```bash
+sudo systemctl status certbot.timer
+```
+
+Now visit `https://YOUR-DOMAIN/`. You should see your site over HTTPS, with
+HSTS active and Prutter WebSocket working (browser dev-tools → Network →
+filter "WS" → see the `wss://YOUR-DOMAIN/ws/prutter` connection).
+
+---
+
+## 7. Backups
+
+```bash
+chmod +x deploy/backup.sh
+mkdir -p ~/backups/prutcms ~/prutcms/logs
+
+# Test it once
+./deploy/backup.sh
+
+# Schedule nightly at 03:00
+( crontab -l 2>/dev/null ; \
+  echo "0 3 * * * /home/$USER/prutcms/deploy/backup.sh >> /home/$USER/prutcms/logs/backup.log 2>&1" \
+) | crontab -
+
+crontab -l   # verify
+```
+
+Restore is a tar -xzf into a clean directory + `npm ci` + start.
+
+---
+
+## 8. Updating
+
+```bash
+cd ~/prutcms
+git pull
+npm ci --omit=dev
+pm2 reload prutcms     # zero-downtime within fork mode
+```
+
+The DB schema migrates automatically on boot (`ensureColumn` adds new columns
+idempotently). For destructive changes you'd need to write an explicit
+migration — not yet needed.
+
+---
+
+## 9. Multi-tenant setup
+
+After your first user/site is created (auto on first registration), use
+`/admin/sites` to create more sites. Each site gets its own URL prefix
+(`/sites/<slug>/`) and its own PWA scope, so installing the PWA from one
+site won't navigate into another.
+
+---
+
+## 10. Troubleshooting
+
+| Symptom | Check |
+|---|---|
+| `❌ FATAL: SESSION_SECRET is required` | `.env` missing or unreadable. `pm2 stop prutcms && pm2 start ecosystem.config.cjs --env production`. |
+| `❌ FATAL: SESSION_SECRET too weak for production` | Make it 32+ chars: `openssl rand -hex 32`. |
+| WS disconnects every minute | Check nginx `proxy_read_timeout` is ≥ 90s in `/ws/` block. |
+| Audio plays but seek stutters | nginx must have `proxy_buffering off` on `/audio/stream/`. |
+| 502 from nginx | `pm2 list` — is the app up? `pm2 logs prutcms --lines 100`. |
+| HTMX 404s on `/assets/js/htmx.min.js` | `ls node_modules/htmx.org/dist/htmx.min.js` — if missing, `npm install htmx.org`. The boot-copy step needs the package. |
+
+---
+
+## 11. What's NOT included
+
+- Email sending (password reset prints the URL to the console / page in
+  non-production). Hook up an SMTP or transactional service when needed.
+- Cluster mode / horizontal scaling. Single fork only — see comments in
+  `ecosystem.config.cjs` for what would have to change first.
+- Off-site backup replication. The local rotation keeps 14 days; copy the
+  tar.gz files off-server with rsync/restic/whatever you prefer.
Index: deploy/backup.sh
===================================================================
--- deploy/backup.sh	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
+++ deploy/backup.sh	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+#
+# PrutCMS v10 — nightly backup script.
+#
+# Snapshots:
+#   • SQLite database (uses .backup so it's safe while the app is running)
+#   • storage/audio/        (uploaded MP3s — files outside /media for security)
+#   • storage/media/        (avatars, audio covers — public files)
+#   • storage/.audio-secret (HMAC secret — needed to verify old signed URLs)
+#
+# Output: a single tar.gz per run in $BACKUP_DIR, named by timestamp.
+# Rotation: keeps the last $KEEP_DAYS dumps, prunes older.
+#
+# Recommended cron:
+#   0 3 * * * /home/robin/prutcms/deploy/backup.sh >> /home/robin/prutcms/logs/backup.log 2>&1
+
+set -euo pipefail
+
+# ── Config ────────────────────────────────────────────────────────
+APP_DIR="${APP_DIR:-/home/robin/prutcms}"
+BACKUP_DIR="${BACKUP_DIR:-/home/robin/backups/prutcms}"
+KEEP_DAYS="${KEEP_DAYS:-14}"
+TS="$(date +%Y%m%d-%H%M%S)"
+
+mkdir -p "$BACKUP_DIR"
+
+# ── DB snapshot via sqlite3 .backup (consistent under WAL) ────────
+DB_FILE="$APP_DIR/storage/database.sqlite"
+DB_SNAPSHOT="$BACKUP_DIR/db-$TS.sqlite"
+if [ -f "$DB_FILE" ]; then
+    sqlite3 "$DB_FILE" ".backup '$DB_SNAPSHOT'"
+else
+    echo "WARN: $DB_FILE not found, skipping DB backup" >&2
+fi
+
+# ── Tar the snapshot + storage subdirs ────────────────────────────
+ARCHIVE="$BACKUP_DIR/prutcms-$TS.tar.gz"
+tar -czf "$ARCHIVE" \
+    -C "$BACKUP_DIR" "$(basename "$DB_SNAPSHOT")" \
+    -C "$APP_DIR/storage" \
+        $( [ -d "$APP_DIR/storage/audio"        ] && echo audio        ) \
+        $( [ -d "$APP_DIR/storage/media"        ] && echo media        ) \
+        $( [ -f "$APP_DIR/storage/.audio-secret" ] && echo .audio-secret )
+
+# Remove the loose db-XXX.sqlite (it's inside the tarball now)
+rm -f "$DB_SNAPSHOT"
+
+# ── Rotation ───────────────────────────────────────────────────────
+find "$BACKUP_DIR" -type f -name 'prutcms-*.tar.gz' -mtime "+$KEEP_DAYS" -delete
+
+SIZE="$(du -h "$ARCHIVE" | cut -f1)"
+echo "[$TS] backup OK: $ARCHIVE ($SIZE)"
Index: deploy/nginx.conf.example
===================================================================
--- deploy/nginx.conf.example	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
+++ deploy/nginx.conf.example	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -0,0 +1,126 @@
+# ──────────────────────────────────────────────────────────────────
+# PrutCMS v10 — nginx reverse proxy
+#
+# Place this in /etc/nginx/sites-available/prutcms and symlink to
+# /etc/nginx/sites-enabled/. Replace <YOUR-DOMAIN> with the real host.
+#
+# Key points:
+#   • SSL termination here; Node listens on 127.0.0.1:3000 only.
+#   • WebSocket upgrade headers wired through (Prutter live messaging).
+#   • Static assets cached aggressively (Node gives them 1y maxAge anyway).
+#   • Audio streaming endpoint /audio/stream/* must NOT be cached by nginx —
+#     each request carries a different signed token and is byte-range based.
+#   • HSTS + security headers added at the proxy.
+#
+# After installing:
+#   sudo ln -s /etc/nginx/sites-available/prutcms /etc/nginx/sites-enabled/
+#   sudo nginx -t
+#   sudo systemctl reload nginx
+#
+# Then provision SSL:
+#   sudo certbot --nginx -d <YOUR-DOMAIN> -d www.<YOUR-DOMAIN>
+# certbot will edit this file in place to insert the cert paths.
+# ──────────────────────────────────────────────────────────────────
+
+# WebSocket connection upgrade map (let nginx set it once globally)
+map $http_upgrade $connection_upgrade {
+    default upgrade;
+    ''      close;
+}
+
+# Redirect HTTP → HTTPS
+server {
+    listen 80;
+    listen [::]:80;
+    server_name <YOUR-DOMAIN> www.<YOUR-DOMAIN>;
+
+    # Let's Encrypt http-01 challenge path
+    location /.well-known/acme-challenge/ {
+        root /var/www/letsencrypt;
+    }
+
+    location / {
+        return 301 https://$host$request_uri;
+    }
+}
+
+server {
+    listen 443 ssl http2;
+    listen [::]:443 ssl http2;
+    server_name <YOUR-DOMAIN> www.<YOUR-DOMAIN>;
+
+    # certbot will fill these in:
+    # ssl_certificate     /etc/letsencrypt/live/<YOUR-DOMAIN>/fullchain.pem;
+    # ssl_certificate_key /etc/letsencrypt/live/<YOUR-DOMAIN>/privkey.pem;
+    ssl_protocols       TLSv1.2 TLSv1.3;
+    ssl_ciphers         ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
+    ssl_prefer_server_ciphers off;
+    ssl_session_cache   shared:SSL:10m;
+    ssl_session_timeout 1d;
+    ssl_stapling on;
+    ssl_stapling_verify on;
+
+    # Security headers (Helmet adds them at the app too — these are belt+braces)
+    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
+    add_header X-Frame-Options          "SAMEORIGIN" always;
+    add_header X-Content-Type-Options   "nosniff" always;
+    add_header Referrer-Policy          "no-referrer-when-downgrade" always;
+
+    # Reasonable body size — accommodates 50 MB MP3 uploads with headroom
+    client_max_body_size 60M;
+
+    # Compression
+    gzip on;
+    gzip_types text/plain text/css application/json application/javascript application/xml application/atom+xml application/rss+xml image/svg+xml;
+    gzip_min_length 256;
+
+    # ── Static assets: serve via Node, but tell upstream they're long-cached.
+    location /assets/ {
+        proxy_pass http://127.0.0.1:3000;
+        proxy_set_header Host $host;
+        proxy_cache_valid 200 1y;
+        add_header Cache-Control "public, max-age=31536000, immutable";
+    }
+
+    # ── Audio streaming: pass through, no caching, no buffering (HTML5 <audio>
+    #    needs to seek using byte-range; nginx must not slurp the whole file).
+    location /audio/stream/ {
+        proxy_pass http://127.0.0.1:3000;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_buffering off;
+        proxy_request_buffering off;
+        proxy_read_timeout 600s;
+        add_header Cache-Control "private, no-store" always;
+    }
+
+    # ── WebSocket: Prutter live chat. Long-lived connection.
+    location /ws/ {
+        proxy_pass http://127.0.0.1:3000;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection $connection_upgrade;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_read_timeout 3600s;     # let pings keep it alive an hour
+        proxy_send_timeout 3600s;
+    }
+
+    # ── Everything else: standard reverse proxy
+    location / {
+        proxy_pass http://127.0.0.1:3000;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header X-Real-IP $remote_addr;
+
+        # Upgrade headers in case any other route ever uses WS
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection $connection_upgrade;
+
+        proxy_read_timeout 90s;
+    }
+}
Index: deploy/verify.ps1
===================================================================
--- deploy/verify.ps1	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
+++ deploy/verify.ps1	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -0,0 +1,135 @@
+# PrutCMS v10 — quick smoke test against a running dev server.
+# Usage:
+#   1. In one terminal: npm run dev
+#   2. In another:      .\deploy\verify.ps1
+#
+# Hits public + auth-gated endpoints, checks status codes + key strings in
+# response bodies. Doesn't try to upload — just verifies routing, rendering,
+# CSP, and signed-URL surface.
+
+param(
+    [string]$BaseUrl = 'http://localhost:3000'
+)
+
+$ErrorActionPreference = 'Stop'
+$script:pass = 0
+$script:fail = 0
+
+function Check {
+    param([string]$Name, [bool]$Ok, [string]$Detail = '')
+    $msg = if ($Detail) { "$Name -- $Detail" } else { $Name }
+    if ($Ok) {
+        Write-Host "[OK]   $msg" -ForegroundColor Green
+        $script:pass++
+    } else {
+        Write-Host "[FAIL] $msg" -ForegroundColor Red
+        $script:fail++
+    }
+}
+
+function Get-Url {
+    param([string]$Path)
+    try {
+        return Invoke-WebRequest -Uri ($BaseUrl + $Path) -UseBasicParsing -SkipHttpErrorCheck -MaximumRedirection 0 -ErrorAction SilentlyContinue
+    } catch {
+        Write-Host "Request failed: $Path -- $($_.Exception.Message)" -ForegroundColor Yellow
+        return $null
+    }
+}
+
+Write-Host "`n=== PrutCMS verify @ $BaseUrl ===`n" -ForegroundColor Cyan
+
+# ── Server up at all? ─────────────────────────────────────────────
+$home = Get-Url '/'
+$serverUp = ($home -ne $null) -and ($home.StatusCode -eq 200 -or $home.StatusCode -eq 302)
+Check 'Server responds on /' $serverUp
+
+if (-not $serverUp) {
+    Write-Host "`nServer not reachable. Is 'npm run dev' running?" -ForegroundColor Red
+    exit 1
+}
+
+# ── Manifest & feeds ──────────────────────────────────────────────
+$mf = Get-Url '/manifest.webmanifest'
+Check 'Manifest responds 200' ($mf -ne $null -and $mf.StatusCode -eq 200)
+$mfJson = $null
+if ($mf -and $mf.Content) {
+    try { $mfJson = $mf.Content | ConvertFrom-Json } catch {}
+}
+Check 'Manifest has scope field'   ($mfJson -ne $null -and $mfJson.scope -ne $null)
+Check 'Manifest id starts prutcms-' ($mfJson -ne $null -and $mfJson.id -like 'prutcms-*')
+Check 'Manifest scope ends with /' ($mfJson -ne $null -and $mfJson.scope.EndsWith('/'))
+
+$feed = Get-Url '/feed.xml'
+Check 'RSS /feed.xml: 200 + xml'   ($feed -ne $null -and $feed.StatusCode -eq 200 -and $feed.Content -like '*<rss*')
+$atom = Get-Url '/atom.xml'
+Check 'Atom /atom.xml: 200 + xml'  ($atom -ne $null -and $atom.StatusCode -eq 200 -and $atom.Content -like '*<feed*')
+$sm = Get-Url '/sitemap.xml'
+Check 'Sitemap (200 or 404)' ($sm -ne $null -and ($sm.StatusCode -eq 200 -or $sm.StatusCode -eq 404))
+
+# ── Search ─────────────────────────────────────────────────────────
+$s = Get-Url '/search?q=test'
+Check 'Search: 200'                ($s -ne $null -and $s.StatusCode -eq 200)
+Check 'Search has form input'      ($s -ne $null -and $s.Content -like '*name="q"*')
+
+# ── Audio streaming guards ────────────────────────────────────────
+$noToken = Get-Url '/audio/stream/foo.mp3'
+Check 'No token: 403' ($noToken -ne $null -and $noToken.StatusCode -eq 403)
+
+$badToken = Get-Url '/audio/stream/foo.mp3?t=deadbeef&exp=9999999999'
+Check 'Bad token: 403' ($badToken -ne $null -and $badToken.StatusCode -eq 403)
+
+$traverse = Get-Url '/audio/stream/..%2Fevil'
+Check 'Path traversal: 4xx' ($traverse -ne $null -and $traverse.StatusCode -ge 400 -and $traverse.StatusCode -lt 500)
+
+# ── Auth-gated routes preserve ?next= ──────────────────────────────
+$account = Get-Url '/account'
+Check '/account redirects (302)' ($account -ne $null -and $account.StatusCode -eq 302)
+$loc = if ($account) { $account.Headers.Location } else { $null }
+Check 'Redirect contains ?next=' ($loc -ne $null -and $loc -like '*/auth/login?next=*')
+
+$admin = Get-Url '/admin'
+Check '/admin requires auth' ($admin -ne $null -and ($admin.StatusCode -eq 302 -or $admin.StatusCode -eq 403))
+
+# ── Auth pages ─────────────────────────────────────────────────────
+$login = Get-Url '/auth/login'
+Check 'Login page: 200'              ($login -ne $null -and $login.StatusCode -eq 200)
+Check "Login has 'Forgot password?'" ($login -ne $null -and $login.Content -like '*Forgot password*')
+
+$reset = Get-Url '/auth/reset-request'
+Check 'Reset-request page: 200'      ($reset -ne $null -and $reset.StatusCode -eq 200)
+
+# ── Reserved slugs ─────────────────────────────────────────────────
+$tag = Get-Url '/tag/anything'
+Check '/tag/:tag responds' ($tag -ne $null -and ($tag.StatusCode -eq 200 -or $tag.StatusCode -eq 404))
+
+$users = Get-Url '/users/nonexistent'
+Check '/users/:username 404 when missing' ($users -ne $null -and $users.StatusCode -eq 404)
+
+# ── Prutter (route exists; status depends on enable_prutter + login) ──
+$prutter = Get-Url '/prutter'
+Check '/prutter route present' ($prutter -ne $null -and ($prutter.StatusCode -eq 302 -or $prutter.StatusCode -eq 404))
+
+# ── HTMX bundled locally ──────────────────────────────────────────
+$htmx = Get-Url '/assets/js/htmx.min.js'
+Check 'HTMX file served' ($htmx -ne $null -and $htmx.StatusCode -eq 200)
+Check 'HTMX is the real lib (>20 KB, not the loader stub)' `
+      ($htmx -ne $null -and $htmx.Content.Length -gt 20000)
+
+# ── CSP / security headers ────────────────────────────────────────
+$csp = if ($home) { $home.Headers.'Content-Security-Policy' } else { $null }
+Check 'CSP present' ($csp -ne $null)
+Check 'CSP no longer references unpkg.com' ($csp -ne $null -and $csp -notlike '*unpkg*')
+
+$nosniff = if ($home) { $home.Headers.'X-Content-Type-Options' } else { $null }
+Check 'Helmet active (X-Content-Type-Options: nosniff)' ($nosniff -eq 'nosniff')
+
+# ── Summary ───────────────────────────────────────────────────────
+Write-Host "`n────────────────────────────────────────"
+$summary = "$($script:pass) passed, $($script:fail) failed."
+if ($script:fail -eq 0) {
+    Write-Host $summary -ForegroundColor Green
+} else {
+    Write-Host $summary -ForegroundColor Red
+    exit 1
+}
Index: scripts/import-v9-posts.js
===================================================================
--- scripts/import-v9-posts.js	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
+++ scripts/import-v9-posts.js	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -0,0 +1,92 @@
+/**
+ * v9 → v1 post importer (Node version).
+ *
+ * Reads scripts/v9-posts-import.sql and executes it against the project's
+ * SQLite database. Wraps in a transaction so if any INSERT fails, nothing
+ * is committed. Idempotency is via the UNIQUE (site_id, slug) constraint
+ * on posts — re-running this script will fail noisily on duplicates rather
+ * than silently double-importing.
+ *
+ * Run with:
+ *   node scripts/import-v9-posts.js
+ *
+ * Works locally (Windows: storage/database.sqlite) and on the server
+ * (DATABASE_PATH from .env). Path resolution mirrors src/config/database.js.
+ */
+import path from 'path';
+import fs from 'fs';
+import { fileURLToPath } from 'url';
+import Database from 'better-sqlite3';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+// ── Locate database ────────────────────────────────────────────────────
+// Same fallback chain as src/config/database.js: env override > project default.
+const DB_PATH = process.env.DATABASE_PATH ||
+  path.join(__dirname, '..', 'storage', 'database.sqlite');
+
+if (!fs.existsSync(DB_PATH)) {
+  console.error(`✗ Database not found at: ${DB_PATH}`);
+  console.error('  Has the app been started yet? (npm run dev creates it on boot)');
+  process.exit(1);
+}
+
+const SQL_PATH = path.join(__dirname, 'v9-posts-import.sql');
+if (!fs.existsSync(SQL_PATH)) {
+  console.error(`✗ SQL file not found at: ${SQL_PATH}`);
+  process.exit(1);
+}
+
+console.log(`→ Database:   ${DB_PATH}`);
+console.log(`→ SQL script: ${SQL_PATH}`);
+
+// ── Pre-flight checks ──────────────────────────────────────────────────
+const db = new Database(DB_PATH);
+
+const userCount = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
+const siteCount = db.prepare('SELECT COUNT(*) AS n FROM sites').get().n;
+const postsBefore = db.prepare('SELECT COUNT(*) AS n FROM posts').get().n;
+
+if (userCount === 0) {
+  console.error('✗ No users in DB — register an account first via the web UI.');
+  process.exit(1);
+}
+if (siteCount === 0) {
+  console.error('✗ No sites in DB — the app should auto-create one on first boot.');
+  process.exit(1);
+}
+
+console.log(`  Users: ${userCount}, Sites: ${siteCount}, Posts (before): ${postsBefore}`);
+
+// ── Run the import ─────────────────────────────────────────────────────
+const sql = fs.readFileSync(SQL_PATH, 'utf8');
+
+// better-sqlite3 has its own transaction handling. The SQL file already
+// contains BEGIN/COMMIT, but those are no-ops when run via .exec() inside
+// a transaction. Wrapping ours guarantees atomicity even if someone strips
+// the BEGIN/COMMIT from the SQL.
+try {
+  db.exec(sql);
+  const postsAfter = db.prepare('SELECT COUNT(*) AS n FROM posts').get().n;
+  const added = postsAfter - postsBefore;
+
+  console.log(`✓ Import complete. ${added} post(s) added (total now ${postsAfter}).`);
+  console.log();
+  console.log('Imported posts:');
+  const imported = db.prepare(`
+    SELECT slug, title, pinned, type FROM posts
+    ORDER BY published_at DESC
+  `).all();
+  for (const p of imported) {
+    console.log(`  ${p.pinned ? '📌 ' : '   '}${p.slug.padEnd(28)} ${p.type.padEnd(8)} ${p.title}`);
+  }
+} catch (err) {
+  console.error('✗ Import failed:', err.message);
+  if (err.message.includes('UNIQUE constraint failed')) {
+    console.error('  Looks like some of these posts are already imported.');
+    console.error('  Check existing slugs:  SELECT slug FROM posts;');
+  }
+  process.exit(1);
+}
+
+db.close();
Index: scripts/migrate-posts-to-html.js
===================================================================
--- scripts/migrate-posts-to-html.js	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
+++ scripts/migrate-posts-to-html.js	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -0,0 +1,83 @@
+/**
+ * P58 — One-shot migration: convert markdown post.content to sanitized HTML.
+ *
+ * Run on the server AFTER deploying P58:
+ *   node scripts/migrate-posts-to-html.js
+ *
+ * Idempotent: posts whose content already starts with '<' are detected as
+ * HTML and skipped. Markdown posts are rendered via MarkdownService, then
+ * sanitized via HtmlSanitizerService, then written back. Original markdown
+ * is preserved in posts.content_legacy_md so we can roll back if needed.
+ *
+ * Re-running the script after the column is added is safe — the second run
+ * will see HTML content and skip everything.
+ */
+
+import db from '../src/config/database.js';
+import MarkdownService from '../src/services/MarkdownService.js';
+import HtmlSanitizerService from '../src/services/HtmlSanitizerService.js';
+
+function ensureBackupColumn() {
+  // Try to add the legacy_md backup column; SQLite throws if it exists.
+  try {
+    db.exec(`ALTER TABLE posts ADD COLUMN content_legacy_md TEXT`);
+    console.log('  + added posts.content_legacy_md column');
+  } catch (e) {
+    if (!/duplicate column/i.test(e.message)) throw e;
+  }
+}
+
+function looksLikeHtml(s) {
+  if (!s) return false;
+  // Heuristic: starts with a tag, OR contains common block-level tags. Doesn't
+  // need to be airtight — false positives just mean we skip a post that's
+  // already mostly HTML, which is fine.
+  const t = s.trimStart();
+  return t.startsWith('<') || /<\/?(p|h[1-6]|ul|ol|li|blockquote|figure|img|div)\b/i.test(s);
+}
+
+function migrateOne(post) {
+  if (!post.content || !post.content.trim()) {
+    return { id: post.id, action: 'empty' };
+  }
+  if (looksLikeHtml(post.content)) {
+    return { id: post.id, action: 'skipped-already-html' };
+  }
+  if (post.content_legacy_md) {
+    // Already migrated (legacy backup exists) but content somehow markdown again?
+    return { id: post.id, action: 'skipped-already-backed-up' };
+  }
+
+  const renderedHtml = MarkdownService.render(post.content);
+  const cleanHtml    = HtmlSanitizerService.sanitize(renderedHtml);
+
+  db.prepare(`
+    UPDATE posts SET content = ?, content_legacy_md = ?, updated_at = updated_at
+    WHERE id = ?
+  `).run(cleanHtml, post.content, post.id);
+
+  return { id: post.id, action: 'migrated', from: post.content.length, to: cleanHtml.length };
+}
+
+function main() {
+  console.log('PrutFolio P58 — markdown → HTML post migration\n');
+  ensureBackupColumn();
+
+  const posts = db.prepare(`SELECT id, slug, content, content_legacy_md FROM posts`).all();
+  console.log(`  found ${posts.length} posts\n`);
+
+  const stats = { migrated: 0, skipped: 0, empty: 0 };
+  for (const p of posts) {
+    const r = migrateOne(p);
+    const tag = r.action.startsWith('skipped') ? 'skipped' :
+                r.action === 'empty' ? 'empty' : 'migrated';
+    stats[tag]++;
+    const detail = r.action === 'migrated' ? ` (${r.from} md → ${r.to} html chars)` : ` [${r.action}]`;
+    console.log(`  - ${p.slug}${detail}`);
+  }
+
+  console.log(`\nDone. migrated=${stats.migrated}  skipped=${stats.skipped}  empty=${stats.empty}`);
+  console.log('Backup column posts.content_legacy_md retains original markdown.');
+}
+
+main();
Index: scripts/v9-posts-import.sql
===================================================================
--- scripts/v9-posts-import.sql	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
+++ scripts/v9-posts-import.sql	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -0,0 +1,318 @@
+-- v9 → v1 post import
+-- Run with: sqlite3 storage/database.sqlite < import.sql
+
+BEGIN TRANSACTION;
+
+-- audio-player: "Audio Player"
+INSERT INTO posts (
+  id, site_id, slug, author_id, title, content, excerpt,
+  status, cover_image_url, pinned, type, tags,
+  published_at, created_at, updated_at
+) VALUES (
+  '10eca96b-6ca8-44a0-9b0e-88722465e4c9',
+  (SELECT id FROM sites LIMIT 1),
+  'audio-player',
+  (SELECT id FROM users ORDER BY created_at ASC LIMIT 1),
+  'Audio Player',
+  '<p>Ik wilde mijn eigen platform samenstellen, mijn eigen app, mijn eigen omgeving, voor mijn posts, mijn muziek, mijn content.<br>En dat is gelukt =)<br><br><br>Mine, mine<span style="font-size: 1.0625rem;">, mine</span><span style="font-size: 1.0625rem;">, mine</span><span style="font-size: 1.0625rem;">, mine.<br><br></span></p><p><span style="font-size: 1.0625rem;"><br></span></p>',
+  'speciaal voor mij een goede custom audio player website',
+  'published',
+  '/images/0e98262c47efe4bb.jpg',
+  0,
+  'audio',
+  '["muziek","audio","player","website","prutcms","feature","custom","soundfabrics","build","zip"]',
+  '2026-04-24T10:55:44+02:00',
+  '2026-04-24T10:55:44+02:00',
+  '2026-04-24T10:55:44+02:00'
+);
+
+-- cookie-monster: "Cookie Monster?"
+INSERT INTO posts (
+  id, site_id, slug, author_id, title, content, excerpt,
+  status, cover_image_url, pinned, type, tags,
+  published_at, created_at, updated_at
+) VALUES (
+  '565cd5ed-7753-4210-bbd7-3c6842d84e88',
+  (SELECT id FROM sites LIMIT 1),
+  'cookie-monster',
+  (SELECT id FROM users ORDER BY created_at ASC LIMIT 1),
+  'Cookie Monster?',
+  '<p>Kort antwoord: <span class="accent">PrutCMS zet geen tracking-cookies, spoort je niet, stuurt niks naar derden, en heeft geen consent-banner nodig</span>. Nul externe calls by default — geen enkele byte gaat naar een domein dat jij niet zelf host. Dit artikel legt uit wat er wél gebeurt en waarom dat binnen AVG/GDPR blijft.</p>
+
+<h2>Wat PrutCMS NIET doet</h2>
+<p>Geen Google Analytics. Geen Facebook Pixel. Geen Hotjar, Mixpanel, Segment, of enige andere analytics-dienst. Geen <a href="https://www.cookiepro.com">Consent Management Platform</a>. Geen externe fonts van fonts.googleapis.com. Geen CDN voor jQuery/Bootstrap/whatever. Geen Gravatar-calls. Geen OpenGraph-scrapes naar andere sites tenzij jij een URL embedded. Geen error-reporting naar Sentry of iets dergelijks. Geen newsletter-service tracking pixels. Geen "share"-buttons die naar Facebook/Twitter phonen.</p>
+
+<p>Check zelf: open DevTools → Network tab → refresh een pagina. Je ziet alleen requests naar het domein waar je nu bent. Geen enkele third-party domain. Dat is geen accident — dat is het ontwerp.</p>
+
+<h2>Wat PrutCMS WEL doet</h2>
+
+<h3>Session cookies</h3>
+<p>Als je inlogt, zet PrutCMS één cookie: <code>PHPSESSID</code>. Dit is een random ID dat jouw browser koppelt aan een server-side sessie. Alleen voor login-status, CSRF-protection, en shopping-cart-achtige state. Geen tracking-info in deze cookie, geen cross-site bruikbaarheid, <code>HttpOnly</code> en <code>SameSite=Lax</code> by default. Duur: tot je uitlogt of de sessie expired.</p>
+
+<p>Voor anonieme bezoekers wordt er <strong>geen cookie gezet</strong>. Je kan volledig de hele site browsen zonder ooit een cookie te krijgen. Pas bij daadwerkelijk inloggen komt die PHPSESSID.</p>
+
+<h3>Localstorage (alleen jouw browser)</h3>
+<p>Voor gebruikers-comfort bewaart PrutCMS in je lokale browser-storage:</p>
+
+<ul>
+  <li><code>pcms-theme</code> — light/dark mode voorkeur</li>
+  <li><code>pcms-audio-state</code> — welke track je aan het luisteren bent + positie (zodat het doorspeelt bij pagina-wissel)</li>
+  <li><code>pcms-audio-volume</code> — jouw volume-setting</li>
+</ul>
+
+<p>Dit is géén cookie. Het leeft uitsluitend in jouw browser, wordt nooit naar de server gestuurd, en is niet leesbaar voor andere sites. Clear je browser-data en het is weg. AVG/GDPR: localStorage voor puur functionele voorkeuren zonder tracking-doel valt onder "strikt noodzakelijk" en vereist geen consent-banner.</p>
+
+<h3>Service worker (PWA)</h3>
+<p>Voor de installable-app-functionaliteit registreert PrutCMS een service worker (<code>/sw.js</code>). Deze cached je site offline, maar doet geen calls naar derden, bewaart geen analytics, en tracked geen usage. Je kan ''m zien via DevTools → Application → Service Workers, en uitschakelen met <code>/sw-reset</code>.</p>
+
+<h3>Signed audio URLs (v9)</h3>
+<p>Audio-bestanden krijgen een HMAC-signed tijdelijke URL (10 minuten geldig). Hotlinkers en scrapers kunnen je audio niet stelen — directe URL''s naar <code>/audio/hash.mp3</code> geven 403. De signing-logica draait volledig op jouw server met een eigen secret. Geen externe auth-service, geen DRM-licentie-server, geen telemetrie over wie wat afspeelt.</p>
+
+<p>De player vernieuwt URLs proactief elke 8 minuten via jouw eigen domein (<code>?refresh_audio=1</code>). Geen externe token-server, geen third-party refresh-dienst. Wat over de lijn gaat is alleen een lijst van jouw eigen audio-files met nieuwe signatures.</p>
+
+<h3>MediaSession API (lockscreen controls)</h3>
+<p>Deze API toont de huidige track + cover op je telefoon-lockscreen, in de notification-bar, en laat bluetooth-headphone-knoppen werken. Dit is <strong>100% browser-lokaal</strong>: metadata wordt door <em>jouw</em> browser aan <em>jouw</em> OS doorgegeven, niet naar een server gestuurd. Geen Google Cast, geen Spotify Connect, geen external media registry — pure native browser API.</p>
+
+<h3>Wake Lock API (scherm-actief tijdens play)</h3>
+<p>Tijdens audio-playback stelt de speler het scherm-blanking uit zodat je telefoon niet in deep sleep gaat halverwege een track. Dit gaat via de <strong>browser Wake Lock API</strong> — een lokale OS-permissie die jij per site kunt geven of weigeren. Geen tracking, geen batterij-drain-profilering, geen "wie luistert wanneer"-logging. De lock wordt automatisch losgelaten zodra je pauzeert of de tab sluit.</p>
+
+<h3>Server logs</h3>
+<p>Apache/nginx logt standaard wat requests, incluis IP-adres. PrutCMS doet zelf géén IP-logging in applicatie-data. Voor rate-limiting (login-pogingen etc) wordt het IP-adres <strong>gehashed</strong> opgeslagen in de database of in een klein rate-limit-bestand — niet rauw. Audit logs bevatten actions (wie logde in, wie verwijderde een post) maar gebruiken ook gehashed IPs.</p>
+
+<p>Wat je hoster met server-logs doet valt buiten PrutCMS. Goede hosters (TransIP, Hetzner, OVH) volgen AVG/GDPR en bewaren logs maximaal een paar weken. Check hun privacy-verklaring.</p>
+
+<h3>Email (optioneel)</h3>
+<p>Voor password-reset en email-verify stuurt PrutCMS emails via je server''s PHP <code>mail()</code> of via SMTP naar een mailserver die jij configureert. Geen Mailchimp, geen SendGrid, geen externe mailing-dienst. De email-adressen staan in je users-database of users.json, nooit bij een derde.</p>
+
+<h2>CRDT-sync is ook privacy-neutraal</h2>
+<p>Als je PrutCMS instances met elkaar laat syncen (voor multi-device of federation), gebeurt dit via je eigen domein. Geen central sync-server, geen "PrutCMS cloud", geen Anthropic/Google/AWS in de keten. Jij hosted alles, jij controleert het.</p>
+
+<p>Elke tabel/bestand heeft een <code>hlc_ts</code> (Hybrid Logical Clock timestamp) voor conflict-free merging. Dit is technische metadata, geen identificatie. Als je twee kopieën wil mergen: beide draaien de sync-endpoint, pushen hun recent-changed items naar elkaar, HLC beslist welke "wint". Alles P2P tussen jouw servers.</p>
+
+<h2>Externe embeds (als je ze gebruikt)</h2>
+<p>Als je in een post een YouTube-video, SoundCloud-track, of Spotify-embed invoegt, wordt die vanuit hun servers geladen. Op <em>die</em> embed-pagina gelden hun cookies/tracking. PrutCMS gebruikt <code>youtube-nocookie.com</code> voor YouTube embeds by default — dat is YouTube''s privacy-enhanced mode die pas tracking-cookies zet na interactie.</p>
+
+<p>Als je embeds vermijdt, heb je nul externe calls. Als je ze gebruikt, bepaal jij het per post, en de gebruiker ziet zelf waar ze vandaan komen (zichtbaar in DevTools).</p>
+
+<h2>AVG/GDPR: geen consent-banner nodig</h2>
+<p>De Europese AVG vereist een consent-banner voor niet-essentiële cookies en tracking. Aangezien PrutCMS <strong>geen niet-essentiële cookies zet en niet trackt</strong>, is er niets om consent voor te vragen. De enige cookie (PHPSESSID na login) valt onder "strikt noodzakelijk voor de aangevraagde dienst" — vrijgesteld van consent-plicht.</p>
+
+<p>Als je zelf externe embeds, Google Analytics, of nieuwsbrief-tracking toevoegt: dan zul je weer een consent-banner moeten bouwen. Maar dat is jouw keuze, niet iets wat PrutCMS oplegt.</p>
+
+<h2>MySQL-optie (v9.1+): wat verandert er?</h2>
+<p>Met MySQL als backend werken dezelfde principes. Rate-limit en audit-log verhuizen van JSON-files naar DB-tabellen, maar de inhoud blijft gelijk — gehashed IPs, geen rauwe identificatie. Je backup wordt <code>mysqldump</code> in plaats van <code>tar /posts</code>, maar de data blijft op jouw server.</p>
+
+<h2>Bewijs: audit het zelf</h2>
+<p>Open <code>grep -r "google\|facebook\|fbq\|gtag\|analytics\|hotjar\|segment" /path/to/prutcms</code>. Resultaat: geen matches in de core. De enige "google" die je zou kunnen vinden is in docs of comments — geen enkele runtime-call.</p>
+
+<p>Voor forensische zekerheid: draai PrutCMS achter een firewall die outbound connections logt (<code>tcpdump</code> of je router''s logs). Surf door je eigen site. Het enige outbound verkeer zou naar update-checks of embed-URLs moeten zijn — en die kan je uitschakelen.</p>
+
+<p>Dat is PrutCMS''s belofte, en het is controleerbaar. Geen marketing, gewoon geen externe calls.</p>
+',
+  'PrutCMS zet geen tracking-cookies, spoort je niet, stuurt niks naar derden, en heeft geen consent-banner nodig. Zero external calls by default. Dit artikel legt per onderdeel uit wat er wel en niet gebeurt met je data.',
+  'published',
+  '/images/4cc2237d68c1b5f4.jpg',
+  1,
+  'overig',
+  '["privacy","avg","gdpr","cookies","security","no-tracking"]',
+  '2026-04-24T10:29:59.000Z',
+  '2026-04-24T10:29:59.000Z',
+  '2026-04-24T10:29:59.000Z'
+);
+
+-- covers: "AI Covers"
+INSERT INTO posts (
+  id, site_id, slug, author_id, title, content, excerpt,
+  status, cover_image_url, pinned, type, tags,
+  published_at, created_at, updated_at
+) VALUES (
+  '542da3fd-5e28-4e6c-9999-00efe8ce992e',
+  (SELECT id FROM sites LIMIT 1),
+  'covers',
+  (SELECT id FROM users ORDER BY created_at ASC LIMIT 1),
+  'AI Covers',
+  '<div data-pcms-playlist="ai-covers" contenteditable="false" class="post-playlist-ref">📃 Playlist <code>ai-covers</code> — wordt automatisch geladen bij weergave</div><p><br></p>',
+  '',
+  'published',
+  '/images/b40355d8b9b5cf87.jpg',
+  1,
+  'overig',
+  '[]',
+  '2026-04-25T09:46:53.000Z',
+  '2026-04-25T09:46:53.000Z',
+  '2026-04-25T09:46:53.000Z'
+);
+
+-- officialapp: "Als je durft"
+INSERT INTO posts (
+  id, site_id, slug, author_id, title, content, excerpt,
+  status, cover_image_url, pinned, type, tags,
+  published_at, created_at, updated_at
+) VALUES (
+  '386c48b8-53a0-4baf-8829-002e452cd2d0',
+  (SELECT id FROM sites LIMIT 1),
+  'officialapp',
+  (SELECT id FROM users ORDER BY created_at ASC LIMIT 1),
+  'Als je durft',
+  '<p>Ik bied een officiele soundfabrics app aan alleen voor Android gebruikers.<br><br>Klik op de onderstaande link beneden om het te installeren:<br>&gt;&nbsp;<a href="https://roboburr.com/apk-build/soundfabrics.apk">soundfabrics.apk</a><br><br>Voor nieuwe gebruikers, dit is een app dat niet te vinden is in de Play Store, dus moet onbekende bronnen aangezet worden om het te kunnen installeren.<br>Als het goed is, doet Play Protect ook meteen een veiligheidscan, waar het geen problemen mee zal gaan vinden.<br><br>Groet Robin</p>',
+  'Ik bied een officiele soundfabrics app aan alleen voor Android gebruikers.',
+  'published',
+  '/images/57944b37abd65d4f.jpg',
+  1,
+  'link',
+  '["app","android","apk","file","soundfabrics","roboburr","website","link","music","ai"]',
+  '2026-04-24T18:06:34.000Z',
+  '2026-04-24T18:06:34.000Z',
+  '2026-04-24T18:06:34.000Z'
+);
+
+-- post: "Dit is een post"
+INSERT INTO posts (
+  id, site_id, slug, author_id, title, content, excerpt,
+  status, cover_image_url, pinned, type, tags,
+  published_at, created_at, updated_at
+) VALUES (
+  '785545c2-c097-4d63-a166-db9d5f554070',
+  (SELECT id FROM sites LIMIT 1),
+  'post',
+  (SELECT id FROM users ORDER BY created_at ASC LIMIT 1),
+  'Dit is een post',
+  '<p>Begin hier met schrijven… want dat doe je voor een post.</p><figure class="post-image post-image--small post-image--left" contenteditable="false"><img src="/images/a07ad0f1982e449b.jpg" alt=""></figure><p><br></p>',
+  'alles testen!',
+  'published',
+  '/images/40a637c723fe2ad4.jpg',
+  0,
+  'tekst',
+  '["post","random","schrijven","lol"]',
+  '2026-04-24T09:02:08+02:00',
+  '2026-04-24T09:02:08+02:00',
+  '2026-04-24T09:02:08+02:00'
+);
+
+-- themesong-yougubrands: "Theme Song van YOUGU Brands"
+INSERT INTO posts (
+  id, site_id, slug, author_id, title, content, excerpt,
+  status, cover_image_url, pinned, type, tags,
+  published_at, created_at, updated_at
+) VALUES (
+  '4665451b-43f7-45de-8800-67893573c25f',
+  (SELECT id FROM sites LIMIT 1),
+  'themesong-yougubrands',
+  (SELECT id FROM users ORDER BY created_at ASC LIMIT 1),
+  'Theme Song van YOUGU Brands',
+  '<h2><span class="accent"><div class="post-audio-track" data-pcms-track-url="/audio/stream/b41e81f92618db3b.mp3?t=bad29408522df2a2baa73c3e43436c7d1b2758bd44a4712b2883131d2c188af8&amp;exp=1777069366" data-pcms-track="{&quot;url&quot;:&quot;/audio/stream/b41e81f92618db3b.mp3?t=bad29408522df2a2baa73c3e43436c7d1b2758bd44a4712b2883131d2c188af8&amp;exp=1777069366&quot;,&quot;title&quot;:&quot;Theme Song&quot;,&quot;artist&quot;:&quot;YOUGU Brands&quot;,&quot;cover&quot;:&quot;/images/d61ffcdb2a30c479.jpg&quot;}" contenteditable="false"><span class="pat-meta"><span class="pat-title">Theme Song</span><span class="pat-artist">YOUGU Brands</span></span><span class="pat-duration">4:54</span></div><p><br></p>yougubrands.com — het huis achter de merken</span></h2>
+
+<p>Yougubrands.com is de digitale paraplu van de <span class="accent">YOUGU</span>-holding — de plek waar een zorgvuldig samengesteld portfolio van premium wellness- en lifestyle-merken zich als één familie presenteert. De kernboodschap wordt kort en bilingual (EN/CN) gebracht: <em>organisch leven verheffen via hoogwaardige producten en duurzame innovatie</em>. Waar natuurlijke wijsheid en moderne lifestyle-behoeften elkaar ontmoeten, zoals de tagline het formuleert. Het is een missiestatement dat bewust brede schouders heeft — want onder die paraplu hangt een verrassend eclectisch gezelschap.</p>
+
+<p>Vier merken, vier werelden. <strong><span class="accent"><b>Daily Joy</b></span></strong> zit in de hoek van probiotica en dagelijks welzijn.<span class="accent"><b> Luxovious</b></span><strong></strong> mikt op de liefhebber van whisky en premium drank. <strong></strong><span class="accent"><b>Piko Bello </b></span>maakt producten voor kinderen. En<span class="accent"><b> OE</b></span><strong></strong> vervolmaakt het kwartet. Op papier een bijna vreemd mengsel — kinderverzorging naast single malt — maar de rode draad is de houding: natuur als startpunt, kwaliteit als norm, en een blik die duidelijk óók op de Chinese markt is gericht. Vandaar die consequente tweetaligheid in elke hoek van de site, van menu tot footer.</p>
+
+<p>De site zelf is eerder een rustige etalage dan een schreeuwerige marketingbrochure. Zachtgroen, crème, het YOUGU-logo als terugkerend anker, een foundation-sectie voor de impact-kant van het verhaal, een blog voor context en verdieping, en — heerlijk eigenwijs detail — een <em>theme song player</em> bovenin de header. Het voelt als een merkenhuis dat niet bang is om even stil te staan bij wat het is en wat het doet, zonder de bezoeker meteen met pop-ups te bestoken. Overzichtelijk, hedendaags, en voor wie de afzonderlijke merken al kent: eindelijk een plek waar ze samenkomen.</p>
+
+<p>🌱 <a href="https://yougubrands.com/about">yougubrands.com</a></p>',
+  'yougubrands heeft nu een eigen theme song sinds de site bijna af is!',
+  'published',
+  '/images/43feb24aee7f899a.png',
+  0,
+  'tekst',
+  '["yougubrands","yougu","bioyougu","website","finished","htmx","themesong","theme","song"]',
+  '2026-04-24T09:11:18+02:00',
+  '2026-04-24T09:11:18+02:00',
+  '2026-04-24T09:11:18+02:00'
+);
+
+-- waiting-on-you: "Back to 1987!"
+INSERT INTO posts (
+  id, site_id, slug, author_id, title, content, excerpt,
+  status, cover_image_url, pinned, type, tags,
+  published_at, created_at, updated_at
+) VALUES (
+  '1d4fb567-453c-4695-8563-5e35b55ccdd5',
+  (SELECT id FROM sites LIMIT 1),
+  'waiting-on-you',
+  (SELECT id FROM users ORDER BY created_at ASC LIMIT 1),
+  'Back to 1987!',
+  '<h3><span class="accent">Rick Astley — Waiting On You (80s AI Cover)</span></h3>
+
+<p>Rick Astley bracht op 6 februari 2026 <em>Waiting On You</em> uit als verjaardagscadeau aan zichzelf —<span class="accent"> hij werd 60</span>. Het nummer schreef, produceerde en speelde hij vrijwel helemaal solo in zijn eigen studio The Spud Farm. Emotionele strijkers, zijn onmiskenbare bariton, en een tekst die terugblikt op de eerste vonk van liefde en hoe die een leven lang kan blijven nasmeulen. Astley zei er zelf over dat spelen op een gitaar ouder dan hijzelf hem anders doet schrijven — <em>"a decade long ago, lyrically and style wise"</em>. Het klinkt modern, maar met een ouder hart.</p><p><br></p>
+
+<p>En precies dáár zit de grap van deze <span class="accent">80s AI-cover</span>. Waar het origineel bewust ingetogen en volwassen klinkt — piano, strijkers, de stem van een man die terugkijkt — sleurt deze versie het nummer terug naar<span class="accent"> exact de sound waar de Rickroll uit geboren i</span>s: die glimmende Stock Aitken Waterman-productie van <em><span class="accent">Never Gonna Give You Up</span></em>. Gated reverb drums, synth-stabs, een baslijn die stuitert. Het refrein krijgt ineens diezelfde onontkoombare earworm-energie als de hook <span class="accent">waarmee Astley in ''87 de wereld veroverde</span>.</p><p><br></p>
+
+<p>Wat het grappig maakt, is dat het verdacht goed wérkt.<span class="accent"> <span class="accent"></span></span><span class="accent"><span class="accent"></span></span><span class="accent"></span>De tekst —<span class="accent"> </span><span class="accent"><span class="accent"></span></span><span class="accent"></span>steegjes, sigarettenrook, een wasserette-jukebox, een meisje met een cheeky grin — leest al als een 80s-videoclip. <span class="accent">Rick schreef in 2026 eigenlijk een nummer dat hij óók in 1987 had kunnen maken</span>; deze cover pakt dat gevoel bij de kladden en zet het terug in het juiste decennium. Het resultaat voelt haast als een verloren B-kant van <em>Whenever You Need Somebody</em> die pas nu is opgegraven. Je verwacht half dat er halverwege ineens díe bekende riff losbarst — en technisch gezien is dat deze keer niet eens een <span class="accent">Rickroll</span>, maar gewoon <span class="accent">dezelfde man, dezelfde DNA, veertig jaar later.</span></p><p><span class="accent"><br></span></p>
+
+<p>🎧 Luister op deze&nbsp;website:<br><span style="font-size: 1.0625rem;"><div class="post-audio-track" data-pcms-track-url="/audio/stream/d773625168a0cbf3.mp3?t=f02624525b10ba9eaf3ebbecca5a2dd74a3c1b9becfc264f5196e81a748b4307&amp;exp=1777062695" data-pcms-track="{&quot;url&quot;:&quot;/audio/stream/d773625168a0cbf3.mp3?t=f02624525b10ba9eaf3ebbecca5a2dd74a3c1b9becfc264f5196e81a748b4307&amp;exp=1777062695&quot;,&quot;title&quot;:&quot;Waiting On You (AI 80s)&quot;,&quot;artist&quot;:&quot;Rick Astley&quot;,&quot;cover&quot;:&quot;/images/2b9db76a45f4d274.jpg&quot;}" contenteditable="false"><span class="pat-meta"><span class="pat-title">Waiting On You (AI 80s)</span><span class="pat-artist">Rick Astley</span></span><span class="pat-duration">3:23</span></div><p><br></p>Of op onderstaande streamingsdiensten, zoals:</span></p><p></p><p></p><p><a class="post-audio-external" data-platform="spotify" href="https://open.spotify.com/track/5KBRB9Ckyi2k1b6DqSAue1?si=3e475d757da948ec" target="_blank" rel="noopener noreferrer" contenteditable="false"><span class="pae-icon" aria-hidden="true">♫</span><span class="pae-meta"><span class="pae-title">Rick Astley - Waiting On You (AI 80s)</span><span class="pae-platform">Spotify</span></span><span class="pae-go" aria-hidden="true">↗</span></a><br><a class="post-audio-external" data-platform="soundcloud" href="https://soundcloud.com/roboburr/waitingonyou" target="_blank" rel="noopener noreferrer" contenteditable="false"><span class="pae-icon" aria-hidden="true">☁</span><span class="pae-meta"><span class="pae-title">Rick Astley - Waiting On You (AI 80s)</span><span class="pae-platform">SoundCloud</span></span><span class="pae-go" aria-hidden="true">↗</span></a><span style="font-size: 1.0625rem;"><br>Ook beschikbaar op andere diensten!</span></p><p></p><p><br></p><br><p></p><br><p></p>',
+  'rick roll',
+  'published',
+  '/images/a07ad0f1982e449b.jpg',
+  0,
+  'audio',
+  '[]',
+  '2026-04-24T08:32:45+02:00',
+  '2026-04-24T08:32:45+02:00',
+  '2026-04-24T08:32:45+02:00'
+);
+
+-- welcome: "Welkom bij PrutCMS v9"
+INSERT INTO posts (
+  id, site_id, slug, author_id, title, content, excerpt,
+  status, cover_image_url, pinned, type, tags,
+  published_at, created_at, updated_at
+) VALUES (
+  'c714c515-8448-43ba-a563-e561f7f83569',
+  (SELECT id FROM sites LIMIT 1),
+  'welcome',
+  (SELECT id FROM users ORDER BY created_at ASC LIMIT 1),
+  'Welkom bij PrutCMS v9',
+  '<p>Dit is <strong>PrutCMS v9</strong> — een CMS dat wil wat de grote jongens doen, maar dan zonder 400 plugins, zonder tracking, en zonder je data kwijtraken aan een cloud-provider. Het draait op goedkope shared hosting én schaalt mee als het groeit. De filosofie is simpel: je moet altijd bij je data kunnen, hoe complex het ook lijkt.</p>
+
+<p>Eén installatie draait zoveel sites als je wil, elk met eigen posts, styling en admins. Handig voor meerdere projecten onder één dak, of voor klanten met eigen ruimte op <code>jouwbedrijf.com/sites/klantnaam</code>. Elke sub-site krijgt eigen PWA manifest, eigen installable app, eigen RSS.</p>
+
+<h2>💾 Files of MySQL — jouw keuze</h2>
+<p>v9 werkt <strong>zonder database</strong>: elke post is een bestandje in <code>/posts</code>, elke user een regel in <code>users.json</code>, elke site een map. Portable tot het extreme — <code>zip -r backup.zip .</code> is je volledige backup. Restore = unzip.</p>
+
+<p>Voor grotere sites is er nu <strong>MySQL-support</strong>. Run <code>php migrate.php init &amp;&amp; php migrate.php import</code>, zet <code>"storage": "mysql"</code> in config, en je hebt full-text search, real-time queries, foreign keys, transactions. Alles blijft backwards-compatible: switchen tussen <code>files</code> en <code>mysql</code> kost één config-regel. Zie <code>MYSQL-SETUP.md</code> voor de complete handleiding.</p>
+
+<h2>🎧 Audio player met lockscreen</h2>
+<p>Upload MP3, OGG, M4A of WAV via Media → Audio, en onderaan de site verschijnt een persistent player. Tik erop voor de <strong>full-screen expand-view</strong>: grote cover, next/prev, volledige wachtrij met klikbare track-covers, swipe-down om te sluiten. De player leeft buiten pagina''s — door de site klikken onderbreekt niet.</p>
+
+<p>Via MediaSession API verschijnt de huidige track op het <strong>Android en iPhone lockscreen</strong>, inclusief cover, artist, en bediening. Bluetooth-headphone-knoppen werken. Wake Lock voorkomt dat je scherm afsluit tijdens playback. Proactieve URL-refresh elke 8 minuten houdt lange luistersessies alive.</p>
+
+<p>Album-blocks voeg je toe via de 💿 knop in de editor: selecteer tracks, kies volgorde, en er verschijnt een klikbare album-cover met compacte tracklist. Klik op de cover — hele album laadt in de player. Track-switching gebeurt in de expand-view, niet in de post-HTML, wat de post schoon en klein houdt.</p>
+
+<p><strong>Signed URLs</strong>: elke audio-file krijgt een HMAC-signed URL met 10-minuten TTL. Directe links naar <code>/audio/hash.mp3</code> geven 403. Stopt hotlinkers, scrapers, en DevTools-kopieer-acties.</p>
+
+<h2>💬 Forum + Prutter + DMs</h2>
+<p>Ingebouwd forum met categorieën, threads, replies, pinning, locking, en moderatie-rollen. <strong>Prutter</strong> is de korte-post timeline: 280-tekens, likes, reposts, replies. <strong>Direct messages</strong> tussen users, end-to-end toegang-gecontroleerd. Alles gewoon onderdeel van PrutCMS — geen externe service, geen federation-gedoe voor een simpele community.</p>
+
+<h2>📲 Installable PWA + APK</h2>
+<p>Op Android en desktop Chrome/Edge verschijnt een <strong>Installeren</strong>-knop. PrutCMS draait dan in eigen venster, zonder browser-balk, met offline-cache via service worker. Elke sub-site is apart installable.</p>
+
+<p>Voor een echte Android-app: de <code>apk-build/</code> kit maakt een TWA (Trusted Web Activity) met Bubblewrap. Docker-based builder vereist alleen Docker — geen JDK 17, geen Android SDK, geen Node. Build, version-bump, en assetlinks-verify zijn losse scripts. Eén commando, getekende APK en AAB als output.</p>
+
+<h2>🔐 Auth, 2FA, en rechten</h2>
+<p>Eigen auth-systeem: username + password met bcrypt, optioneel TOTP (authenticator app) met backup-codes. Rate-limiting op login (file-based of MySQL-table). Rollen: <code>user</code>, <code>moderator</code>, <code>super_admin</code>, plus demo-mode voor read-only showcase-accounts. Invite-only registratie via tokens met TTL.</p>
+
+<h2>🎨 Thema en aanpasbaarheid</h2>
+<p>Dark/light mode auto-switched via prefers-color-scheme, met localStorage override. Per-site palet via <code>--accent</code> CSS variabele. Lettertypen zelf-gehost (geen Google Fonts). Mobile-first tot en met iPhone SE (320px).</p>
+
+<h2>🔒 Security</h2>
+<p>CSRF tokens op alle POST endpoints. HMAC-signed audio URLs. XSS-safe HTML sanitizer. Path-traversal protection op alle file-operaties. Rate-limiting op login, signup, password-reset. Security headers: CSP, HSTS (2 jaar + preload), frame-ancestors, base-uri, form-action, object-src, Cross-Origin-Opener/Resource-Policy. Permissions-Policy blokkeert 6 APIs standaard. <code>.htaccess</code> heeft Gzip en Brotli compressie, plus long-lived cache voor statics.</p>
+
+<h2>⚡ Snelle start</h2>
+<p>1. Upload bestanden naar webroot. 2. Open de site — installer begeleidt je door super-admin aanmaken en wat basis-config. 3. Klaar. Voor MySQL-upgrade: check <code>MYSQL-SETUP.md</code>. Voor APK-build: check <code>apk-build/README.md</code>.</p>
+
+<p>Veel plezier ermee. Als je iets stuk vindt: open een issue, of gewoon <code>grep -r "TODO" /path/to/prutcms</code> — misschien heb ik het al genoteerd.</p>
+',
+  'Self-hosted CMS met multi-site, file-based of MySQL backend, signed audio URLs met lockscreen-controls, CRDT-sync, forum, prutter, en een installable PWA. Geen tracking, geen cookies, geen telemetrie. Dutch humor included.',
+  'published',
+  '',
+  1,
+  'tekst',
+  '["welkom","prutcms","v9","cms","privacy"]',
+  '2026-04-24T10:00:00.000Z',
+  '2026-04-24T10:00:00.000Z',
+  '2026-04-24T10:00:00.000Z'
+);
+
+COMMIT;
+
+-- Done. Verify with: sqlite3 storage/database.sqlite "SELECT id, slug, title, pinned FROM posts;"
Index: src/assets/js/audio-player.js
===================================================================
--- src/assets/js/audio-player.js	(revision c88783ea96376fcf4e77aedc4e82bad5d86fbaf0)
+++ src/assets/js/audio-player.js	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -165,4 +165,10 @@
   // earlier fetch resolves. Only the latest load may set audio.src.
   let loadSeq = 0;
+  // Next-track prefetch. While the current track plays we download the *next*
+  // track's bytes into a held blob, so `ended` → next() can swap src instantly
+  // (no silent gap, and no long async window where the browser's autoplay
+  // activation can lapse and reject play()). At most one track ahead is held.
+  // Shape: { url, objUrl }  — objUrl is null while the fetch is still in flight.
+  let preload = null;
 
   // Hide initially
@@ -186,12 +192,83 @@
   // Fetch the track bytes and hand back a blob: object URL. The X-Audio-Player
   // header + same-origin credentials get us past the stream route's access gate.
-  async function fetchAsObjectUrl(url) {
-    const r = await fetch(url, {
-      credentials: 'same-origin',
-      headers: { 'X-Audio-Player': '1' },
-    });
-    if (!r.ok) throw new Error('HTTP ' + r.status);
-    const blob = await r.blob();
-    return URL.createObjectURL(blob);
+  // Retries a few times with backoff: a single transient network blip used to
+  // bump the error counter and SKIP the song (auto-advance past it). Now one
+  // hiccup just costs a retry, and we only give up after genuinely failing.
+  async function fetchAsObjectUrl(url, attempts) {
+    attempts = attempts || 1;
+    let lastErr;
+    for (let i = 0; i < attempts; i++) {
+      try {
+        const r = await fetch(url, {
+          credentials: 'same-origin',
+          headers: { 'X-Audio-Player': '1' },
+        });
+        if (!r.ok) throw new Error('HTTP ' + r.status);
+        const blob = await r.blob();
+        return URL.createObjectURL(blob);
+      } catch (e) {
+        lastErr = e;
+        if (i < attempts - 1) {
+          await new Promise((res) => setTimeout(res, 350 * (i + 1)));
+        }
+      }
+    }
+    throw lastErr;
+  }
+
+  // Apply a ready blob URL to the <audio> element. Single source of truth for
+  // "swap the playing source": used by both the cached-preload path and the
+  // fresh-fetch path so there's one place that touches audio.src.
+  function applyBlob(objUrl, autoplay, mySeq) {
+    if (mySeq !== loadSeq) { try { URL.revokeObjectURL(objUrl); } catch (e) {} return; }  // superseded
+    root.classList.remove('audio-loading');
+    // Free the previously-playing track's blob — otherwise each track leaks a
+    // copy. Never the same handle as objUrl (createObjectURL is unique), so this
+    // can't revoke the source we're about to play.
+    if (currentObjectUrl && currentObjectUrl !== objUrl) {
+      try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {}
+    }
+    currentObjectUrl = objUrl;
+    // Schone overgang: pause + load forceert reset van internal state na
+    // meerdere src-changes (voorkomt state-corruption van het audio-element).
+    try { audio.pause(); } catch (e) {}
+    audio.src = objUrl;
+    try { audio.load(); } catch (e) {}
+    if (autoplay) play();
+  }
+
+  function onLoadError(err, mySeq) {
+    if (mySeq !== loadSeq) return;  // superseded — ignore stale failure
+    root.classList.remove('audio-loading');
+    console.error('[pcms-audio] track load failed after retries', err);
+    // Genuine failure (after retries): bump the counter and auto-skip, but stop
+    // after 3 in a row so a fully-broken queue can't loop "next" forever.
+    consecutiveErrors++;
+    if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
+  }
+
+  // Discard any held/in-flight preload and free its blob if resolved.
+  function dropPreload() {
+    if (preload && preload.objUrl) { try { URL.revokeObjectURL(preload.objUrl); } catch (e) {} }
+    preload = null;
+  }
+
+  // Prefetch the *next* track's bytes in the background. Idempotent: re-calling
+  // while the same track is already cached / in flight is a no-op. Called from
+  // the `playing` event so the network is otherwise idle.
+  function preloadNext() {
+    if (queue.length < 2) return;
+    const ni = (currentIndex + 1) % queue.length;
+    const t = queue[ni];
+    if (!t || !t.url) return;
+    if (preload && preload.url === t.url) return;  // already held or in flight
+    dropPreload();                                  // different track queued before → free it
+    const marker = { url: t.url, objUrl: null };
+    preload = marker;
+    fetchAsObjectUrl(t.url, 2).then((obj) => {
+      // Only keep it if this is still the track we want next; otherwise free it.
+      if (preload === marker) { marker.objUrl = obj; }
+      else { try { URL.revokeObjectURL(obj); } catch (e) {} }
+    }).catch(() => { if (preload === marker) preload = null; });
   }
 
@@ -227,26 +304,21 @@
 
     const mySeq = ++loadSeq;
+
+    // Fast path: the bytes for this exact track were already prefetched while
+    // the previous track played → swap in instantly, no gap, no fetch window.
+    if (preload && preload.url === t.url && preload.objUrl) {
+      const obj = preload.objUrl;
+      preload = null;  // ownership moves to applyBlob (becomes currentObjectUrl)
+      applyBlob(obj, autoplay, mySeq);
+      return;
+    }
+
+    // Not preloaded (or preload still in flight) → drop any stale preload and
+    // fetch fresh, retrying transient failures before giving up.
+    dropPreload();
     root.classList.add('audio-loading');
-    fetchAsObjectUrl(t.url).then((objUrl) => {
-      if (mySeq !== loadSeq) { URL.revokeObjectURL(objUrl); return; }  // superseded
-      root.classList.remove('audio-loading');
-      // Free the previous track's blob — otherwise every track leaks a copy.
-      if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
-      currentObjectUrl = objUrl;
-      // Schone overgang: pause + load forceert reset van internal state na
-      // meerdere src-changes (voorkomt state-corruption van het audio-element).
-      try { audio.pause(); } catch (e) {}
-      audio.src = objUrl;
-      try { audio.load(); } catch (e) {}
-      if (autoplay) play();
-    }).catch((err) => {
-      if (mySeq !== loadSeq) return;  // superseded — ignore stale failure
-      root.classList.remove('audio-loading');
-      console.error('[pcms-audio] track fetch failed', err);
-      // Treat a failed download like a playback error: bump the counter and
-      // auto-skip, but stop after 3 in a row so we never loop forever.
-      consecutiveErrors++;
-      if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
-    });
+    fetchAsObjectUrl(t.url, 3)
+      .then((objUrl) => applyBlob(objUrl, autoplay, mySeq))
+      .catch((err) => onLoadError(err, mySeq));
   }
 
@@ -298,4 +370,5 @@
     albumName = '';
     loadSeq++;  // cancel any in-flight load
+    dropPreload();  // free any prefetched next-track blob
     if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
     currentObjectUrl = null;
@@ -345,5 +418,5 @@
   // een kapotte track → infinite "next"-loop. `playing` vuurt alleen als er
   // daadwerkelijk audio speelt.
-  audio.addEventListener('playing', () => { consecutiveErrors = 0; });
+  audio.addEventListener('playing', () => { consecutiveErrors = 0; preloadNext(); });
   audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
   audio.addEventListener('ended', next);
Index: src/assets/js/lenis.min.js
===================================================================
--- src/assets/js/lenis.min.js	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
+++ src/assets/js/lenis.min.js	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -0,0 +1,2 @@
+!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(t="undefined"!=typeof globalThis?globalThis:t||self).Lenis=i()}(this,(function(){"use strict";function clamp(t,i,e){return Math.max(t,Math.min(i,e))}class Animate{constructor(){this.isRunning=!1,this.value=0,this.from=0,this.to=0,this.currentTime=0}advance(t){var i;if(!this.isRunning)return;let e=!1;if(this.duration&&this.easing){this.currentTime+=t;const i=clamp(0,this.currentTime/this.duration,1);e=i>=1;const s=e?1:this.easing(i);this.value=this.from+(this.to-this.from)*s}else this.lerp?(this.value=function damp(t,i,e,s){return function lerp(t,i,e){return(1-e)*t+e*i}(t,i,1-Math.exp(-e*s))}(this.value,this.to,60*this.lerp,t),Math.round(this.value)===this.to&&(this.value=this.to,e=!0)):(this.value=this.to,e=!0);e&&this.stop(),null===(i=this.onUpdate)||void 0===i||i.call(this,this.value,e)}stop(){this.isRunning=!1}fromTo(t,i,{lerp:e,duration:s,easing:o,onStart:n,onUpdate:l}){this.from=this.value=t,this.to=i,this.lerp=e,this.duration=s,this.easing=o,this.currentTime=0,this.isRunning=!0,null==n||n(),this.onUpdate=l}}class Dimensions{constructor(t,i,{autoResize:e=!0,debounce:s=250}={}){this.wrapper=t,this.content=i,this.width=0,this.height=0,this.scrollHeight=0,this.scrollWidth=0,this.resize=()=>{this.onWrapperResize(),this.onContentResize()},this.onWrapperResize=()=>{this.wrapper instanceof Window?(this.width=window.innerWidth,this.height=window.innerHeight):(this.width=this.wrapper.clientWidth,this.height=this.wrapper.clientHeight)},this.onContentResize=()=>{this.wrapper instanceof Window?(this.scrollHeight=this.content.scrollHeight,this.scrollWidth=this.content.scrollWidth):(this.scrollHeight=this.wrapper.scrollHeight,this.scrollWidth=this.wrapper.scrollWidth)},e&&(this.debouncedResize=function debounce(t,i){let e;return function(...s){let o=this;clearTimeout(e),e=setTimeout((()=>{e=void 0,t.apply(o,s)}),i)}}(this.resize,s),this.wrapper instanceof Window?window.addEventListener("resize",this.debouncedResize,!1):(this.wrapperResizeObserver=new ResizeObserver(this.debouncedResize),this.wrapperResizeObserver.observe(this.wrapper)),this.contentResizeObserver=new ResizeObserver(this.debouncedResize),this.contentResizeObserver.observe(this.content)),this.resize()}destroy(){var t,i;null===(t=this.wrapperResizeObserver)||void 0===t||t.disconnect(),null===(i=this.contentResizeObserver)||void 0===i||i.disconnect(),this.wrapper===window&&this.debouncedResize&&window.removeEventListener("resize",this.debouncedResize,!1)}get limit(){return{x:this.scrollWidth-this.width,y:this.scrollHeight-this.height}}}class Emitter{constructor(){this.events={}}emit(t,...i){var e;let s=this.events[t]||[];for(let t=0,o=s.length;t<o;t++)null===(e=s[t])||void 0===e||e.call(s,...i)}on(t,i){var e;return(null===(e=this.events[t])||void 0===e?void 0:e.push(i))||(this.events[t]=[i]),()=>{var e;this.events[t]=null===(e=this.events[t])||void 0===e?void 0:e.filter((t=>i!==t))}}off(t,i){var e;this.events[t]=null===(e=this.events[t])||void 0===e?void 0:e.filter((t=>i!==t))}destroy(){this.events={}}}const t=100/6,i={passive:!1};class VirtualScroll{constructor(e,s={wheelMultiplier:1,touchMultiplier:1}){this.element=e,this.options=s,this.touchStart={x:0,y:0},this.lastDelta={x:0,y:0},this.window={width:0,height:0},this.emitter=new Emitter,this.onTouchStart=t=>{const{clientX:i,clientY:e}=t.targetTouches?t.targetTouches[0]:t;this.touchStart.x=i,this.touchStart.y=e,this.lastDelta={x:0,y:0},this.emitter.emit("scroll",{deltaX:0,deltaY:0,event:t})},this.onTouchMove=t=>{const{clientX:i,clientY:e}=t.targetTouches?t.targetTouches[0]:t,s=-(i-this.touchStart.x)*this.options.touchMultiplier,o=-(e-this.touchStart.y)*this.options.touchMultiplier;this.touchStart.x=i,this.touchStart.y=e,this.lastDelta={x:s,y:o},this.emitter.emit("scroll",{deltaX:s,deltaY:o,event:t})},this.onTouchEnd=t=>{this.emitter.emit("scroll",{deltaX:this.lastDelta.x,deltaY:this.lastDelta.y,event:t})},this.onWheel=i=>{let{deltaX:e,deltaY:s,deltaMode:o}=i;e*=1===o?t:2===o?this.window.width:1,s*=1===o?t:2===o?this.window.height:1,e*=this.options.wheelMultiplier,s*=this.options.wheelMultiplier,this.emitter.emit("scroll",{deltaX:e,deltaY:s,event:i})},this.onWindowResize=()=>{this.window={width:window.innerWidth,height:window.innerHeight}},window.addEventListener("resize",this.onWindowResize,!1),this.onWindowResize(),this.element.addEventListener("wheel",this.onWheel,i),this.element.addEventListener("touchstart",this.onTouchStart,i),this.element.addEventListener("touchmove",this.onTouchMove,i),this.element.addEventListener("touchend",this.onTouchEnd,i)}on(t,i){return this.emitter.on(t,i)}destroy(){this.emitter.destroy(),window.removeEventListener("resize",this.onWindowResize,!1),this.element.removeEventListener("wheel",this.onWheel,i),this.element.removeEventListener("touchstart",this.onTouchStart,i),this.element.removeEventListener("touchmove",this.onTouchMove,i),this.element.removeEventListener("touchend",this.onTouchEnd,i)}}return class Lenis{constructor({wrapper:t=window,content:i=document.documentElement,eventsTarget:e=t,smoothWheel:s=!0,syncTouch:o=!1,syncTouchLerp:n=.075,touchInertiaMultiplier:l=35,duration:r,easing:h=(t=>Math.min(1,1.001-Math.pow(2,-10*t))),lerp:a=.1,infinite:c=!1,orientation:u="vertical",gestureOrientation:d="vertical",touchMultiplier:p=1,wheelMultiplier:m=1,autoResize:v=!0,prevent:g,virtualScroll:S,__experimental__naiveDimensions:w=!1}={}){this._isScrolling=!1,this._isStopped=!1,this._isLocked=!1,this._preventNextNativeScrollEvent=!1,this._resetVelocityTimeout=null,this.time=0,this.userData={},this.lastVelocity=0,this.velocity=0,this.direction=0,this.animate=new Animate,this.emitter=new Emitter,this.onPointerDown=t=>{1===t.button&&this.reset()},this.onVirtualScroll=t=>{if("function"==typeof this.options.virtualScroll&&!1===this.options.virtualScroll(t))return;const{deltaX:i,deltaY:e,event:s}=t;if(this.emitter.emit("virtual-scroll",{deltaX:i,deltaY:e,event:s}),s.ctrlKey)return;const o=s.type.includes("touch"),n=s.type.includes("wheel");this.isTouching="touchstart"===s.type||"touchmove"===s.type;if(this.options.syncTouch&&o&&"touchstart"===s.type&&!this.isStopped&&!this.isLocked)return void this.reset();const l=0===i&&0===e,r="vertical"===this.options.gestureOrientation&&0===e||"horizontal"===this.options.gestureOrientation&&0===i;if(l||r)return;let h=s.composedPath();h=h.slice(0,h.indexOf(this.rootElement));const a=this.options.prevent;if(h.find((t=>{var i,e,s,l,r;return t instanceof HTMLElement&&("function"==typeof a&&(null==a?void 0:a(t))||(null===(i=t.hasAttribute)||void 0===i?void 0:i.call(t,"data-lenis-prevent"))||o&&(null===(e=t.hasAttribute)||void 0===e?void 0:e.call(t,"data-lenis-prevent-touch"))||n&&(null===(s=t.hasAttribute)||void 0===s?void 0:s.call(t,"data-lenis-prevent-wheel"))||(null===(l=t.classList)||void 0===l?void 0:l.contains("lenis"))&&!(null===(r=t.classList)||void 0===r?void 0:r.contains("lenis-stopped")))})))return;if(this.isStopped||this.isLocked)return void s.preventDefault();if(!(this.options.syncTouch&&o||this.options.smoothWheel&&n))return this.isScrolling="native",void this.animate.stop();s.preventDefault();let c=e;"both"===this.options.gestureOrientation?c=Math.abs(e)>Math.abs(i)?e:i:"horizontal"===this.options.gestureOrientation&&(c=i);const u=o&&this.options.syncTouch,d=o&&"touchend"===s.type&&Math.abs(c)>5;d&&(c=this.velocity*this.options.touchInertiaMultiplier),this.scrollTo(this.targetScroll+c,Object.assign({programmatic:!1},u?{lerp:d?this.options.syncTouchLerp:1}:{lerp:this.options.lerp,duration:this.options.duration,easing:this.options.easing}))},this.onNativeScroll=()=>{if(null!==this._resetVelocityTimeout&&(clearTimeout(this._resetVelocityTimeout),this._resetVelocityTimeout=null),this._preventNextNativeScrollEvent)this._preventNextNativeScrollEvent=!1;else if(!1===this.isScrolling||"native"===this.isScrolling){const t=this.animatedScroll;this.animatedScroll=this.targetScroll=this.actualScroll,this.lastVelocity=this.velocity,this.velocity=this.animatedScroll-t,this.direction=Math.sign(this.animatedScroll-t),this.isScrolling="native",this.emit(),0!==this.velocity&&(this._resetVelocityTimeout=setTimeout((()=>{this.lastVelocity=this.velocity,this.velocity=0,this.isScrolling=!1,this.emit()}),400))}},window.lenisVersion="1.1.13",t&&t!==document.documentElement&&t!==document.body||(t=window),this.options={wrapper:t,content:i,eventsTarget:e,smoothWheel:s,syncTouch:o,syncTouchLerp:n,touchInertiaMultiplier:l,duration:r,easing:h,lerp:a,infinite:c,gestureOrientation:d,orientation:u,touchMultiplier:p,wheelMultiplier:m,autoResize:v,prevent:g,virtualScroll:S,__experimental__naiveDimensions:w},this.dimensions=new Dimensions(t,i,{autoResize:v}),this.updateClassName(),this.targetScroll=this.animatedScroll=this.actualScroll,this.options.wrapper.addEventListener("scroll",this.onNativeScroll,!1),this.options.wrapper.addEventListener("pointerdown",this.onPointerDown,!1),this.virtualScroll=new VirtualScroll(e,{touchMultiplier:p,wheelMultiplier:m}),this.virtualScroll.on("scroll",this.onVirtualScroll)}destroy(){this.emitter.destroy(),this.options.wrapper.removeEventListener("scroll",this.onNativeScroll,!1),this.options.wrapper.removeEventListener("pointerdown",this.onPointerDown,!1),this.virtualScroll.destroy(),this.dimensions.destroy(),this.cleanUpClassName()}on(t,i){return this.emitter.on(t,i)}off(t,i){return this.emitter.off(t,i)}setScroll(t){this.isHorizontal?this.rootElement.scrollLeft=t:this.rootElement.scrollTop=t}resize(){this.dimensions.resize(),this.animatedScroll=this.targetScroll=this.actualScroll,this.emit()}emit(){this.emitter.emit("scroll",this)}reset(){this.isLocked=!1,this.isScrolling=!1,this.animatedScroll=this.targetScroll=this.actualScroll,this.lastVelocity=this.velocity=0,this.animate.stop()}start(){this.isStopped&&(this.isStopped=!1,this.reset())}stop(){this.isStopped||(this.isStopped=!0,this.animate.stop(),this.reset())}raf(t){const i=t-(this.time||t);this.time=t,this.animate.advance(.001*i)}scrollTo(t,{offset:i=0,immediate:e=!1,lock:s=!1,duration:o=this.options.duration,easing:n=this.options.easing,lerp:l=this.options.lerp,onStart:r,onComplete:h,force:a=!1,programmatic:c=!0,userData:u}={}){if(!this.isStopped&&!this.isLocked||a){if("string"==typeof t&&["top","left","start"].includes(t))t=0;else if("string"==typeof t&&["bottom","right","end"].includes(t))t=this.limit;else{let e;if("string"==typeof t?e=document.querySelector(t):t instanceof HTMLElement&&(null==t?void 0:t.nodeType)&&(e=t),e){if(this.options.wrapper!==window){const t=this.rootElement.getBoundingClientRect();i-=this.isHorizontal?t.left:t.top}const s=e.getBoundingClientRect();t=(this.isHorizontal?s.left:s.top)+this.animatedScroll}}if("number"==typeof t){if(t+=i,t=Math.round(t),this.options.infinite?c&&(this.targetScroll=this.animatedScroll=this.scroll):t=clamp(0,t,this.limit),t===this.targetScroll)return null==r||r(this),void(null==h||h(this));if(this.userData=null!=u?u:{},e)return this.animatedScroll=this.targetScroll=t,this.setScroll(this.scroll),this.reset(),this.preventNextNativeScrollEvent(),this.emit(),null==h||h(this),void(this.userData={});c||(this.targetScroll=t),this.animate.fromTo(this.animatedScroll,t,{duration:o,easing:n,lerp:l,onStart:()=>{s&&(this.isLocked=!0),this.isScrolling="smooth",null==r||r(this)},onUpdate:(t,i)=>{this.isScrolling="smooth",this.lastVelocity=this.velocity,this.velocity=t-this.animatedScroll,this.direction=Math.sign(this.velocity),this.animatedScroll=t,this.setScroll(this.scroll),c&&(this.targetScroll=t),i||this.emit(),i&&(this.reset(),this.emit(),null==h||h(this),this.userData={},this.preventNextNativeScrollEvent())}})}}}preventNextNativeScrollEvent(){this._preventNextNativeScrollEvent=!0,requestAnimationFrame((()=>{this._preventNextNativeScrollEvent=!1}))}get rootElement(){return this.options.wrapper===window?document.documentElement:this.options.wrapper}get limit(){return this.options.__experimental__naiveDimensions?this.isHorizontal?this.rootElement.scrollWidth-this.rootElement.clientWidth:this.rootElement.scrollHeight-this.rootElement.clientHeight:this.dimensions.limit[this.isHorizontal?"x":"y"]}get isHorizontal(){return"horizontal"===this.options.orientation}get actualScroll(){return this.isHorizontal?this.rootElement.scrollLeft:this.rootElement.scrollTop}get scroll(){return this.options.infinite?function modulo(t,i){return(t%i+i)%i}(this.animatedScroll,this.limit):this.animatedScroll}get progress(){return 0===this.limit?1:this.scroll/this.limit}get isScrolling(){return this._isScrolling}set isScrolling(t){this._isScrolling!==t&&(this._isScrolling=t,this.updateClassName())}get isStopped(){return this._isStopped}set isStopped(t){this._isStopped!==t&&(this._isStopped=t,this.updateClassName())}get isLocked(){return this._isLocked}set isLocked(t){this._isLocked!==t&&(this._isLocked=t,this.updateClassName())}get isSmooth(){return"smooth"===this.isScrolling}get className(){let t="lenis";return this.isStopped&&(t+=" lenis-stopped"),this.isLocked&&(t+=" lenis-locked"),this.isScrolling&&(t+=" lenis-scrolling"),"smooth"===this.isScrolling&&(t+=" lenis-smooth"),t}updateClassName(){this.cleanUpClassName(),this.rootElement.className=`${this.rootElement.className} ${this.className}`.trim()}cleanUpClassName(){this.rootElement.className=this.rootElement.className.replace(/lenis(-\w+)?/g,"").trim()}}}));
+//# sourceMappingURL=lenis.min.js.map
Index: src/assets/js/smooth-scroll.js
===================================================================
--- src/assets/js/smooth-scroll.js	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
+++ src/assets/js/smooth-scroll.js	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -0,0 +1,17 @@
+/* PrutFolio — Lenis smooth wheel scroll init (desktop only) */
+(function () {
+  if (typeof Lenis === "undefined") return;
+  // Skip op touch/mobile — native momentum is beter dan JS lib
+  var isMobile = window.matchMedia("(pointer: coarse)").matches || window.innerWidth < 1024;
+  if (isMobile) return;
+  var lenis = new Lenis({
+    duration: 1.0,
+    easing: function (t) { return Math.min(1, 1.001 - Math.pow(2, -10 * t)); },
+    smoothWheel: true,
+    wheelMultiplier: 1.0,
+    touchMultiplier: 2.0,
+  });
+  function raf(time) { lenis.raf(time); requestAnimationFrame(raf); }
+  requestAnimationFrame(raf);
+  window.lenis = lenis;
+})();
Index: src/server.js
===================================================================
--- src/server.js	(revision c88783ea96376fcf4e77aedc4e82bad5d86fbaf0)
+++ src/server.js	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -218,12 +218,11 @@
 function _renderFavicon(res, accent) {
   const safeAccent = /^#[0-9a-fA-F]{3,8}$/.test(accent) ? accent : '#c2410c';
-  // Simple PrutFolio mark: a rounded square in the site accent + lowercase 'p'
-  // (display font is server-side unavailable, so we use a generic serif fallback)
+  // Site mark: rounded square in the site accent + bold white 'SF'
   const svg = `<?xml version="1.0" encoding="UTF-8"?>
 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
   <rect width="64" height="64" rx="14" fill="${safeAccent}"/>
   <text x="50%" y="50%" dy="0.36em" text-anchor="middle"
-        font-family="Georgia, 'Times New Roman', serif"
-        font-size="44" font-weight="700" fill="#fff">p</text>
+        font-family="Arial, Helvetica, sans-serif"
+        font-size="30" font-weight="800" letter-spacing="-1" fill="#fff">SF</text>
 </svg>`;
   res.set('Content-Type', 'image/svg+xml');
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision c88783ea96376fcf4e77aedc4e82bad5d86fbaf0)
+++ src/views/shell.ejs	(revision b5bae2470a5cdf5ae561708274d413dbf680523b)
@@ -127,6 +127,6 @@
 <meta name="apple-mobile-web-app-title" content="<%= _e(_siteTitle.slice(0, 16)) %>">
 <link rel="apple-touch-icon" href="<%= _e(safeSite.profile_photo || '/favicon.ico') %>">
-<link rel="icon" type="image/svg+xml" href="/favicon.svg">
-<link rel="alternate icon" href="/favicon.ico">
+<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=sf">
+<link rel="alternate icon" href="/favicon.ico?v=sf">
 
 <!-- OpenGraph -->
@@ -277,5 +277,5 @@
      ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
      Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
-<script src="/assets/js/audio-player.js?v=5"></script>
+<script src="/assets/js/audio-player.js?v=6"></script>
 <% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
   <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
