xgit simple git

nexus

nexus

clone git clone https://kb.hax.al/nexus

commit e9f221fccb15da68e7c43c82a7c11a4d9a4e9af1
author: arianit <arianitkukaj@gmail.com>
date: 2026-03-26 21:42
parents: (none)

Nexus.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d1fcee1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,749 @@
+<div align="center">
+
+<img src="https://img.shields.io/badge/PHP-8.0+-777BB4?style=for-the-badge&logo=php&logoColor=white" alt="PHP 8.0+">
+<img src="https://img.shields.io/badge/SQLite-003B57?style=for-the-badge&logo=sqlite&logoColor=white" alt="SQLite">
+<img src="https://img.shields.io/badge/MySQL-4479A1?style=for-the-badge&logo=mysql&logoColor=white" alt="MySQL">
+<img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="MIT License">
+<img src="https://img.shields.io/badge/Zero-Dependencies-orange?style=for-the-badge" alt="Zero Dependencies">
+
+<br><br>
+
+```
+███╗   ██╗███████╗██╗  ██╗██╗   ██╗███████╗
+████╗  ██║██╔════╝╚██╗██╔╝██║   ██║██╔════╝
+██╔██╗ ██║█████╗   ╚███╔╝ ██║   ██║███████╗
+██║╚██╗██║██╔══╝   ██╔██╗ ██║   ██║╚════██║
+██║ ╚████║███████╗██╔╝ ██╗╚██████╔╝███████║
+╚═╝  ╚═══╝╚══════╝╚═╝  ╚═╝ ╚═════╝ ╚══════╝
+```
+
+### A modern, full-featured discussion platform built with pure PHP 8
+
+**No frameworks · No npm · No Docker · Just upload and run**
+
+[Features](#-features) · [Quick Start](#-quick-start) · [Installation](#-installation) · [Configuration](#️-configuration) · [Addons](#-addon-system) · [API](#-api-reference) · [FAQ](#-faq)
+
+</div>
+
+---
+
+## ✨ Features
+
+### 💬 Forum Core
+- **Categories** with icons, colours, sub-categories, and per-role permissions
+- **Topics & threaded replies** with pagination
+- **Full Markdown editor** — Google Docs-style toolbar with SVG icons, Write/Preview tabs
+- **Syntax-highlighted code blocks** via Prism.js (200+ languages, lazy-loaded only when needed)
+- **Styled blockquotes** with gradient left border
+- **Post permalinks** — every post gets `#post-{id}` + a 🔗 copy-link button (pagination-aware)
+- **Inline image upload** — paste, drag-drop, or file picker directly in the editor
+- **Media auto-embeds** — paste a URL and it becomes a player (14 platforms)
+- **@mentions** with live autocomplete
+- **Live search** — finds topics AND post content, links directly to the matching post on the correct page
+
+### 🔐 Roles & Permissions
+
+| Role | Level | Can Do |
+|---|---|---|
+| **Guest** | 0 | Read public categories |
+| **Member** | 10 | Post, reply, like, message, friend |
+| **Moderator** | 20 | + Pin/close topics, edit any post |
+| **Admin** | 30 | Full access + admin panel |
+
+**Per-category permissions** — set independently for reading, posting, and replying:
+
+| Permission | Options |
+|---|---|
+| Who can **read** | 🌐 Everyone · 👤 Members · 🛡️ Moderators+ · 👑 Admins |
+| Who can **post topics** | Same four options |
+| Who can **reply** | Same four options |
+
+### ⭐ Karma System
+
+Eight progressive tiers earned through activity:
+
+| Tier | Points | Icon |
+|---|---|---|
+| Newcomer | 0–9 | 🌱 |
+| Member | 10–49 | 💬 |
+| Regular | 50–99 | ⭐ |
+| Contributor | 100–249 | 🌟 |
+| Veteran | 250–499 | 🔥 |
+| Expert | 500–999 | 💎 |
+| Elite | 1000–2499 | 👑 |
+| Legend | 2500+ | 🏆 |
+
+Admins can manually adjust karma (Add / Subtract / Set) with an optional reason that notifies the user.
+
+### 📬 Private Messages
+- Inbox/Sent with unread badges
+- Conversation threads displayed as chat bubbles
+- Read receipts (✓ sent · ✓✓ read)
+- Online status indicator (green if active in last 5 min)
+- Live user search autocomplete
+
+### 🔍 Search
+- **Topics tab** — title matches
+- **Posts tab** — content matches, jumps directly to the exact post on the correct page
+- **Users tab** — username + bio search
+- Live header dropdown shows topic + post results simultaneously
+
+### 🧩 Addon System
+Extend the forum by dropping a folder into `addons/` and clicking Activate. No core file edits needed. Full PHP API access with 9 event hooks.
+
+### 🔒 Security
+- CSRF tokens on all forms and AJAX
+- bcrypt password hashing (cost 12)
+- Math captcha (admin toggle, separate for posts and new topics)
+- Rate limiting with live countdown
+- Auto-generated `.htaccess` protection for `data/` and `uploads/`
+- Security headers: `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, HSTS on HTTPS
+- All SQL via PDO prepared statements
+
+### 🗄️ Database Support
+
+| Driver | Version |
+|---|---|
+| **SQLite** | 3.x — zero configuration, single file |
+| **MySQL** | 5.7+ |
+| **MariaDB** | 10.3+ |
+
+Schema migrates automatically on every request — update files and existing installs upgrade themselves.
+
+---
+
+## 🚀 Quick Start
+
+### Shared Hosting (5 minutes)
+
+```bash
+# 1. Upload to your server
+scp -r forum-clean/ user@host:~/public_html/forum/
+
+# 2. Set directory permissions
+chmod 755 data/ public/uploads/ public/uploads/avatars/
+
+# 3. Visit the installer
+# https://yoursite.com/forum/install/
+
+# 4. Complete the 3-step wizard, then remove /install/
+rm -rf install/
+```
+
+### Local Development
+
+```bash
+# PHP built-in server — SQLite, zero config
+cd forum-clean/
+php -S localhost:8080
+# open http://localhost:8080/install/
+```
+
+### Docker (Apache)
+
+```dockerfile
+# Dockerfile
+FROM php:8.2-apache
+RUN docker-php-ext-install pdo pdo_sqlite
+RUN a2enmod rewrite
+COPY forum-clean/ /var/www/html/
+RUN chown -R www-data:www-data /var/www/html/data \
+    /var/www/html/public/uploads
+```
+
+```bash
+docker build -t nexus-forum .
+docker run -p 8080:80 nexus-forum
+# open http://localhost:8080/install/
+```
+
+---
+
+## 📦 Installation
+
+### Requirements
+
+| Item | Minimum | Notes |
+|---|---|---|
+| PHP | **8.0** | 8.2+ recommended |
+| PDO | Required | `pdo_sqlite` or `pdo_mysql` |
+| GD | Optional | For image thumbnails |
+| Web server | Apache or Nginx | See configs below |
+| Disk | 10 MB | Plus user uploads |
+
+### Step-by-step
+
+**1 — Upload files**
+
+The forum works at any URL path:
+- `https://yoursite.com/`
+- `https://yoursite.com/forum/`
+- `https://yoursite.com/community/board/`
+
+The `BASE` path is auto-detected. No `.env` changes needed.
+
+**2 — Set permissions**
+
+```bash
+chmod 755 data/
+chmod 755 public/uploads/
+chmod 755 public/uploads/avatars/
+```
+
+**3 — Run the web installer**
+
+Visit `/install/` — the 3-step wizard:
+
+| Step | What happens |
+|---|---|
+| **1 — Requirements** | Checks PHP version, extensions, directory permissions |
+| **2 — Database** | Choose SQLite or MySQL, enter site name + admin credentials |
+| **3 — Done** | Writes config, runs migration, shows security checklist |
+
+**4 — Post-install (automatic)**
+
+The installer automatically creates:
+- `data/.htaccess` — denies all web access to the database directory
+- `public/uploads/.htaccess` — blocks PHP execution in uploads folder
+- `data/db_config.php` → `chmod 0640`
+- `data/forum.db` → `chmod 0640` (SQLite only)
+- `data/installed.lock` — prevents re-running the installer
+
+### MySQL Setup
+
+```sql
+CREATE DATABASE nexus_forum
+  CHARACTER SET utf8mb4
+  COLLATE utf8mb4_unicode_ci;
+
+CREATE USER 'nexus'@'localhost' IDENTIFIED BY 'your_strong_password';
+GRANT ALL PRIVILEGES ON nexus_forum.* TO 'nexus'@'localhost';
+FLUSH PRIVILEGES;
+```
+
+Then select "MySQL / MariaDB" in the installer.
+
+---
+
+## ⚙️ Configuration
+
+### Apache
+
+```apache
+<VirtualHost *:80>
+    ServerName forum.yoursite.com
+    DocumentRoot /var/www/nexus-forum
+
+    <Directory /var/www/nexus-forum>
+        AllowOverride All
+        Require all granted
+    </Directory>
+
+    # Protect database directory
+    <Directory /var/www/nexus-forum/data>
+        Require all denied
+    </Directory>
+</VirtualHost>
+```
+
+### Nginx
+
+```nginx
+server {
+    listen 80;
+    server_name forum.yoursite.com;
+    root /var/www/nexus-forum;
+    index index.php;
+
+    # Block sensitive paths
+    location ~ ^/(data|includes)/ {
+        deny all;
+        return 404;
+    }
+    location ~ \.(db|sqlite|lock)$ {
+        deny all;
+        return 404;
+    }
+    # Block PHP execution in uploads
+    location ~ ^/public/uploads/.*\.php$ {
+        deny all;
+    }
+
+    location ~ \.php$ {
+        fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
+        include fastcgi_params;
+        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+    }
+
+    location / {
+        try_files $uri $uri/ =404;
+    }
+}
+```
+
+### Admin Settings Panel
+
+Visit **Admin → Settings** to configure:
+
+| Setting | Description |
+|---|---|
+| Site name & description | Header and `<title>` |
+| Topics / Posts per page | Pagination sizes |
+| Post captcha | Math captcha on replies (spam protection) |
+| Topic captcha | Math captcha on new topics |
+| Rate limiting | Seconds between posts |
+| Max upload size | Image upload limit |
+| Registration | Open or closed |
+
+---
+
+## 📁 Project Structure
+
+```
+nexus-forum/
+│
+├── 📁 addons/                   # Drop addon folders here
+│   └── example-hello-world/    # Sample addon (see Addon docs)
+│
+├── 📁 admin/                   # Admin panel
+│   ├── index.php               # Dashboard
+│   ├── users.php               # User list
+│   ├── user.php                # Edit user + karma manager
+│   ├── categories.php          # Categories + role permissions
+│   ├── topics.php              # Topic moderation
+│   ├── settings.php            # Site settings
+│   ├── themes.php              # Theme switching
+│   └── addons.php              # Addon manager + developer docs
+│
+├── 📁 api/                     # JSON endpoints (POST)
+│   ├── reply.php               # Post a reply
+│   ├── edit.php                # Edit a post
+│   ├── delete.php              # Delete a post
+│   ├── like.php                # Like / unlike
+│   ├── upload.php              # Image upload
+│   ├── notifications.php       # Mark read
+│   ├── friend.php              # Friend requests
+│   ├── karma.php               # Admin karma adjust
+│   ├── chat.php                # Private message actions
+│   ├── search.php              # Live search (topics + posts)
+│   ├── search_users.php        # User autocomplete
+│   └── topic_action.php        # Pin / close / delete topic
+│
+├── 📁 auth/                    # login · register · logout
+├── 📁 data/                    # Created by installer (not web-accessible)
+├── 📁 forum/                   # category · topic · new-topic · search
+│
+├── 📁 includes/                # Core library (not web-accessible)
+│   ├── bootstrap.php           # Loads everything, boots addons
+│   ├── config.php              # Path detection, security headers
+│   ├── db.php                  # PDO multi-driver DB class
+│   ├── functions.php           # All helpers
+│   ├── markdown.php            # Markdown + embed renderer
+│   └── addons.php              # AddonManager class
+│
+├── 📁 install/                 # DELETE after setup
+├── 📁 messages/                # inbox · compose · view
+│
+├── 📁 public/
+│   ├── css/main.css            # ~2400 lines — full design system
+│   ├── js/app.js               # ~1000 lines — all client JS
+│   └── uploads/                # User images (PHP execution blocked)
+│
+├── 📁 users/                   # profile · edit · search
+│
+├── 📁 views/partials/
+│   ├── layout.php              # Header, sidebar, nav
+│   ├── layout_end.php          # Footer, Prism.js loader, app.js
+│   ├── admin_layout.php        # Admin sidebar
+│   └── editor_toolbar.php      # Reusable Markdown toolbar (SVG icons)
+│
+└── index.php                   # Homepage
+```
+
+---
+
+## 📺 Media Embeds
+
+Paste any of these URLs alone on a line in a post and it auto-embeds as a player:
+
+| Platform | Supported |
+|---|---|
+| YouTube | Videos, Shorts, YouTube Music |
+| Vimeo | Videos |
+| Twitch | Live streams, VODs |
+| Dailymotion | Videos |
+| Streamable | Clips |
+| Rumble | Videos |
+| Spotify | Tracks, albums, playlists, podcast episodes, artist pages |
+| SoundCloud | Tracks |
+| Loom | Screen recordings |
+| CodePen | Pens |
+| JSFiddle | Fiddles |
+| Twitter / X | Tweets |
+| TED Talks | Talks |
+| Bandcamp | Tracks |
+
+---
+
+## 🧩 Addon System
+
+### Installing
+
+1. Drop the addon folder into `addons/`
+2. **Admin → Addons → ▶ Activate**
+
+### Creating an Addon
+
+**`nexus-addon.json`** — manifest (required)
+
+```json
+{
+  "name":        "My Addon",
+  "description": "What this addon does.",
+  "version":     "1.0.0",
+  "author":      "Your Name",
+  "url":         "https://yoursite.com",
+  "hooks":       ["after_topic_created", "render_post_footer"],
+  "requires":    { "nexus": ">=14" }
+}
+```
+
+**`main.php`** — entry point (required)
+
+```php
+<?php
+// Runs on every request when the addon is active
+
+// Inject HTML below every post
+addon_on('render_post_footer', function(array $post): string {
+    return '<div class="my-badge">✓ Verified</div>';
+});
+
+// React to new topics
+addon_on('after_topic_created', function(array $data): void {
+    // $data: topic_id, title, slug, category_id, user_id
+    // Call external webhook, send Slack message, etc.
+    // file_get_contents('https://hooks.example.com?title=' . urlencode($data['title']));
+});
+
+// Filter post HTML before display
+addon_on('render_post_content', function(string $html): string {
+    return str_replace(':-)', '😊', $html);
+});
+```
+
+**`install.php`** — runs on activation (optional)
+
+```php
+<?php
+DB::connect()->exec("CREATE TABLE IF NOT EXISTS my_log (
+    id         INTEGER PRIMARY KEY AUTOINCREMENT,
+    message    TEXT NOT NULL,
+    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+)");
+cfg_set('my_addon_active', '1');
+```
+
+**`uninstall.php`** — runs on deactivation (optional)
+
+```php
+<?php
+cfg_set('my_addon_active', '0');
+// DB::run("DROP TABLE IF EXISTS my_log");  // uncomment to clean up
+```
+
+### Hook Reference
+
+| Hook | Data passed | Return | Fires when |
+|---|---|---|---|
+| `after_topic_created` | `array` {topic_id, title, slug, category_id, user_id} | void | Topic saved |
+| `after_reply_saved` | `array` {post_id, topic_id, user_id} | void | Reply posted |
+| `after_user_registered` | `array` {user_id, username, email} | void | Registration |
+| `render_post_content` | `string` HTML | `string` HTML | Before post output |
+| `render_post_footer` | `array` post row | `string` HTML | Below post body |
+| `render_topic_header` | `array` topic row | `string` HTML | Above topic |
+| `user_karma_changed` | `array` {user_id, old, new, by} | void | Karma adjusted |
+| `admin_nav_items` | `array` items | `array` | Admin sidebar |
+| `before_page_head` | `string` HTML | `string` HTML | Inside `<head>` |
+
+### Addon PHP API
+
+```php
+// Database
+DB::rows("SELECT * FROM topics WHERE category_id=?", [$catId]);
+DB::row("SELECT * FROM users WHERE id=?", [$uid]);
+DB::insert("INSERT INTO my_log (message) VALUES (?)", [$msg]);
+DB::run("UPDATE my_table SET col=? WHERE id=?", [$val, $id]);
+DB::val("SELECT COUNT(*) FROM posts WHERE topic_id=?", [$tid]);
+
+// Current user
+global $USER; // array or null
+
+// Settings
+$val = cfg('site_name', 'My Forum');
+cfg_set('my_key', 'my_value');
+
+// Notifications
+add_notification($userId, 'my_type', ['key' => 'value']);
+
+// Karma
+add_karma($userId, 10);   // add 10 points
+
+// URL helpers
+$url = u('forum/topic.php?slug=' . urlencode($slug));
+$assetUrl = asset('js/app.js');
+```
+
+---
+
+## 🎨 Theming
+
+All design tokens are CSS custom properties in `public/css/main.css`:
+
+```css
+:root {
+  /* Brand colours */
+  --blue:    #3b82f6;
+  --blue-d:  #2563eb;
+  --blue-l:  #eff6ff;
+  --green:   #22c55e;
+  --red:     #ef4444;
+  --purple:  #8b5cf6;
+
+  /* Surfaces */
+  --bg:       #f1f5f9;   /* page background */
+  --surface:  #ffffff;   /* cards */
+  --border:   #e2e8f0;   /* borders */
+  --border-l: #f1f5f9;   /* light borders */
+
+  /* Text */
+  --text:   #0f172a;
+  --muted:  #64748b;
+  --faint:  #94a3b8;
+
+  /* Typography */
+  --font: 'Inter', -apple-system, sans-serif;
+  --mono: 'JetBrains Mono', 'Fira Code', monospace;
+
+  /* Sizing */
+  --r:    6px;    /* border radius */
+  --r-lg: 10px;
+  --r-xl: 16px;
+  --header:  56px;
+  --sidebar: 220px;
+}
+```
+
+Override any variable in a custom stylesheet, or inject one via the `before_page_head` addon hook.
+
+---
+
+## 🔑 API Reference
+
+All endpoints accept `POST` (or `GET` for search) and expect a `csrf` parameter from the `NX.csrf` global.
+
+| Endpoint | Auth | Description |
+|---|---|---|
+| `POST /api/reply.php` | Member | Post a reply (`slug`, `content`) |
+| `POST /api/edit.php` | Author/Admin | Edit post (`post_id`, `content`) |
+| `POST /api/delete.php` | Author/Admin | Delete post (`post_id`) |
+| `POST /api/like.php` | Member | Like/unlike (`post_id`) |
+| `POST /api/upload.php` | Member | Upload image (`file`) → `{url}` |
+| `POST /api/topic_action.php` | Mod/Admin | Pin/close/delete topic |
+| `POST /api/friend.php` | Member | Friend actions (`action`, `other_id`) |
+| `POST /api/karma.php` | Admin | Adjust karma (`user_id`, `amount`, `op`) |
+| `POST /api/notifications.php` | Member | Mark notifications read |
+| `GET  /api/search.php?q=` | Public | Live search → `{topics, posts}` |
+| `GET  /api/search_users.php?q=` | Public | User autocomplete → `[{id, username, avatar}]` |
+| `POST /api/chat.php` | Member | DM actions (`action`: send/poll/load/conversations) |
+
+**Quick example — posting a reply:**
+
+```javascript
+const fd = new FormData();
+fd.append('slug',    'my-topic-slug');
+fd.append('content', 'My reply content here.');
+fd.append('csrf',    NX.csrf);  // NX is the global config object
+
+const res  = await fetch(NX.base + '/api/reply.php', { method: 'POST', body: fd });
+const data = await res.json();
+// Success: { ok: true, post: { id, content, post_num, created_at, username, ... } }
+// Error:   { error: "message", ... }
+```
+
+---
+
+## 🧰 Developer Reference
+
+### Helper Functions
+
+| Function | Returns | Description |
+|---|---|---|
+| `e($val)` | `string` | `htmlspecialchars()` — always use when outputting user data |
+| `u($path)` | `string` | URL with BASE prefix |
+| `asset($path)` | `string` | Public asset URL |
+| `go($path)` | never | Redirect |
+| `post($key, $default)` | `mixed` | `$_POST[$key] ?? $default` |
+| `get($key, $default)` | `mixed` | `$_GET[$key] ?? $default` |
+| `cfg($key, $default)` | `string` | Read a setting (cached) |
+| `cfg_set($key, $value)` | void | Write a setting |
+| `sanitise($input)` | `string` | Strip HTML/PHP/scripts from user input |
+| `must_login()` | void | Redirect if not authenticated |
+| `must_admin()` | void | Redirect if not admin |
+| `is_admin()` | `bool` | Check admin role |
+| `current_user()` | `?array` | Current user row or null |
+| `csrf_input()` | `string` | `<input type="hidden" name="csrf" value="...">` |
+| `csrf_ok()` | `bool` | Validate CSRF token |
+| `render_post($raw)` | `string` | Render Markdown + embeds to HTML |
+| `add_karma($uid, $pts)` | void | Add (or subtract) karma points |
+| `karma_tier($karma)` | `array` | Tier name, icon, colour, progress |
+| `add_notification($uid, $type, $data)` | void | Queue a notification |
+| `can_read_category($cat)` | `bool` | Read permission check |
+| `can_post_topic($cat)` | `bool` | Post permission check |
+| `can_reply_topic($cat)` | `bool` | Reply permission check |
+| `unique_slug($title, $table)` | `string` | Generate a unique URL slug |
+| `rate_check($uid, $type)` | `array` | `{ok, wait}` |
+| `rate_record($uid, $type)` | void | Record a rate-limited action |
+| `addon_hook($hook, $data)` | `mixed` | Fire an addon hook |
+
+### Database Class
+
+```php
+DB::rows($sql, $params)   // array of rows
+DB::row($sql, $params)    // one row or null
+DB::insert($sql, $params) // int lastInsertId
+DB::run($sql, $params)    // PDOStatement
+DB::val($sql, $params)    // scalar or null
+DB::now()                 // cross-driver: NOW() or datetime('now')
+DB::isMysql()             // bool
+DB::insertIgnore($table, $cols, $vals)   // cross-driver INSERT IGNORE
+DB::upsert($table, $keyCol, $valCol, $key, $val)  // cross-driver upsert
+```
+
+### Adding a New Page
+
+```php
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_login();   // or must_admin(), or omit for public pages
+
+$PAGE_TITLE = 'My Page';
+include __DIR__ . '/../views/partials/layout.php';
+?>
+
+<h1>Hello, <?= e($USER['username']) ?></h1>
+<p>Your karma: <?= (int)$USER['karma'] ?></p>
+
+<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>
+```
+
+---
+
+## 🛡️ Security Checklist
+
+After going live:
+
+- [ ] **Delete `install/`** — prevents re-installation
+- [ ] **Verify `data/.htaccess`** — should deny all HTTP access (auto-created)
+- [ ] **Verify `public/uploads/.htaccess`** — should block `.php` execution (auto-created)
+- [ ] **Use HTTPS** — HSTS header is sent automatically when detected
+- [ ] **MySQL users** — grant only `SELECT`, `INSERT`, `UPDATE`, `DELETE` (not `DROP`)
+- [ ] **Enable captcha** — Admin → Settings → Post Captcha / Topic Captcha
+- [ ] **Set rate limits** — Admin → Settings → Rate Limiting
+
+---
+
+## ❓ FAQ
+
+<details>
+<summary><strong>Can I run this on shared hosting without shell access?</strong></summary>
+
+Yes. Shared hosting is the primary target. Everything is configured through the web installer. No Composer, npm, or shell access required.
+</details>
+
+<details>
+<summary><strong>Do I need a separate database server?</strong></summary>
+
+No. SQLite works out of the box with zero configuration — the database is a single file in `data/`. You can switch to MySQL/MariaDB any time by re-running the installer.
+</details>
+
+<details>
+<summary><strong>How do I upgrade to a new version?</strong></summary>
+
+Replace all files except `data/`. The schema migration runs automatically on the first page load after the update, adding any new columns safely with `IF NOT EXISTS` / `information_schema` checks.
+</details>
+
+<details>
+<summary><strong>How do I reset a forgotten admin password?</strong></summary>
+
+Run from the command line in your forum directory:
+
+```bash
+php -r "
+require 'includes/bootstrap.php';
+\$hash = password_hash('new_password_here', PASSWORD_BCRYPT, ['cost' => 12]);
+DB::run('UPDATE users SET password=? WHERE role=?', [\$hash, 'admin']);
+echo 'Password reset successfully.';
+"
+```
+</details>
+
+<details>
+<summary><strong>Can I use this behind a reverse proxy / load balancer?</strong></summary>
+
+Yes. The `BASE` path is auto-detected from `DOCUMENT_ROOT` vs `SCRIPT_FILENAME`. No `.env` changes needed. For HTTPS detection behind a proxy, ensure the proxy sets `X-Forwarded-Proto: https`.
+</details>
+
+<details>
+<summary><strong>How do I back up the forum?</strong></summary>
+
+**SQLite:** Copy `data/forum.db` and `data/db_config.php`.
+
+**MySQL:** `mysqldump nexus_forum > backup.sql`
+
+Also back up `public/uploads/` for user images.
+</details>
+
+<details>
+<summary><strong>Why no Composer / npm?</strong></summary>
+
+The goal is maximum deployability. Any server running PHP 8 with PDO can run Nexus — no package manager, no build step, no Node.js. The only optional CDN dependency is Prism.js for syntax highlighting, which is lazy-loaded only when a code block is on the page.
+</details>
+
+---
+
+## 🤝 Contributing
+
+1. Fork the repository
+2. Create a branch: `git checkout -b feature/my-feature`
+3. Make your changes — test on **both SQLite and MySQL**
+4. Syntax check: `find . -name "*.php" | xargs php -l`
+5. Submit a pull request
+
+### Code Guidelines
+
+- **PHP 8.0+** — use `match`, arrow functions, named arguments freely
+- **No raw SQL interpolation** — always use PDO prepared statements
+- **Always `e()` user output** — never echo user data unescaped
+- **Cross-driver SQL** — test on both SQLite and MySQL; use `DB::insertIgnore()`, `DB::upsert()`, `DB::now()` for portability
+- **No external dependencies** — no Composer packages, no npm, no build step
+
+---
+
+## 📄 License
+
+MIT License — free to use, modify, and distribute.
+
+---
+
+<div align="center">
+
+**Nexus Discussion**
+
+*Built with PHP 8 · PDO · Vanilla JS*
+
+*No frameworks · No build steps · No Docker required*
+
+**[⬆ Back to top](#nexus-discussion)**
+
+</div>
diff --git a/addons/example-hello-world/install.php b/addons/example-hello-world/install.php
new file mode 100644
index 0000000..f9f7811
--- /dev/null
+++ b/addons/example-hello-world/install.php
@@ -0,0 +1,4 @@
+<?php
+// install.php — runs when admin activates the addon
+if (!defined('NEXUS')) exit('Forbidden');
+cfg_set('hello_world_enabled', '1');
diff --git a/addons/example-hello-world/main.php b/addons/example-hello-world/main.php
new file mode 100644
index 0000000..5e3433d
--- /dev/null
+++ b/addons/example-hello-world/main.php
@@ -0,0 +1,18 @@
+<?php
+/**
+ * Hello World — Example addon
+ * Appends a small note below every post footer. Demonstrates the hook system.
+ */
+if (!defined('NEXUS')) exit('Forbidden');
+
+addon_on('render_post_footer', function (array $post): string {
+    // Return empty string to add nothing, or HTML to append below the post
+    return ''; // Disabled by default — remove the return to activate
+    return '<div style="font-size:11px;color:#94a3b8;margin-top:8px;padding-top:6px;'
+         . 'border-top:1px solid var(--border)">Hello World addon is active ✓</div>';
+}, 999);
+
+addon_on('after_topic_created', function (array $data): void {
+    // $data: topic_id, title, slug, category_id, user_id
+    // Example: error_log("New topic created: " . $data['title']);
+});
diff --git a/addons/example-hello-world/nexus-addon.json b/addons/example-hello-world/nexus-addon.json
new file mode 100644
index 0000000..6d1635b
--- /dev/null
+++ b/addons/example-hello-world/nexus-addon.json
@@ -0,0 +1,9 @@
+{
+  "name":        "Hello World",
+  "description": "A sample addon that demonstrates the Nexus addon API. Adds a small footer note to every post.",
+  "version":     "1.0.0",
+  "author":      "Nexus Forum",
+  "url":         "",
+  "hooks": ["render_post_footer", "after_topic_created"],
+  "requires": { "nexus": ">=14" }
+}
diff --git a/addons/example-hello-world/uninstall.php b/addons/example-hello-world/uninstall.php
new file mode 100644
index 0000000..fbc02b4
--- /dev/null
+++ b/addons/example-hello-world/uninstall.php
@@ -0,0 +1,4 @@
+<?php
+// uninstall.php — runs when admin deactivates the addon
+if (!defined('NEXUS')) exit('Forbidden');
+cfg_set('hello_world_enabled', '0');
diff --git a/addons/welcome-guide/install.php b/addons/welcome-guide/install.php
new file mode 100644
index 0000000..5fb35f4
--- /dev/null
+++ b/addons/welcome-guide/install.php
@@ -0,0 +1,3 @@
+<?php
+if (!defined('NEXUS')) exit('Forbidden');
+cfg_set('welcome_guide_enabled', '1');
diff --git a/addons/welcome-guide/main.php b/addons/welcome-guide/main.php
new file mode 100644
index 0000000..264cda5
--- /dev/null
+++ b/addons/welcome-guide/main.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * Welcome & Formatting Guide Addon
+ *
+ * Fires on render_post_footer for post #1 of every topic.
+ * Shows a collapsible formatting cheatsheet under the first post.
+ * wgToggle() is defined in public/js/app.js — no inline script needed.
+ */
+if (!defined('NEXUS')) exit('Forbidden');
+
+addon_on('render_post_footer', function (array $post): string {
+
+    // Only show on the first post (the OP) in every topic
+    if ((int)($post['post_num'] ?? 0) !== 1) return '';
+
+    // Respect admin on/off toggle
+    if (cfg('welcome_guide_enabled', '1') !== '1') return '';
+
+    return '
+<div class="wg-guide" id="wgGuide">
+  <button class="wg-toggle" onclick="wgToggle()" aria-expanded="false">
+    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
+         stroke-linecap="round" stroke-linejoin="round" width="13" height="13">
+      <circle cx="12" cy="12" r="10"/>
+      <line x1="12" y1="8" x2="12" y2="12"/>
+      <line x1="12" y1="16" x2="12.01" y2="16"/>
+    </svg>
+    Formatting guide
+    <svg class="wg-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor"
+         stroke-width="2.5" stroke-linecap="round" width="11" height="11">
+      <polyline points="6 9 12 15 18 9"/>
+    </svg>
+  </button>
+  <div class="wg-body" style="display:none">
+    <div class="wg-grid">
+
+      <div class="wg-section">
+        <div class="wg-section-title">Text Formatting</div>
+        <div class="wg-row"><code class="wg-mono">**bold**</code>   <span><strong>bold</strong></span></div>
+        <div class="wg-row"><code class="wg-mono">*italic*</code>   <span><em>italic</em></span></div>
+        <div class="wg-row"><code class="wg-mono">~~strike~~</code> <span><del>strike</del></span></div>
+        <div class="wg-row"><code class="wg-mono">[text](url)</code><span><a href="#">link</a></span></div>
+      </div>
+
+      <div class="wg-section">
+        <div class="wg-section-title">Code</div>
+        <div class="wg-row">
+          <code class="wg-mono">`inline`</code>
+          <span><code style="background:#f5f3ff;color:#7c3aed;padding:1px 5px;border-radius:4px;font-size:12px">inline</code></span>
+        </div>
+        <div class="wg-row wg-block-row">
+          <div>
+            <code class="wg-mono">```php</code><br>
+            <code class="wg-mono">echo "hi";</code><br>
+            <code class="wg-mono">```</code>
+          </div>
+          <span>→ purple code block</span>
+        </div>
+        <div class="wg-hint">Languages: php, js, python, sql, html, css, bash…</div>
+      </div>
+
+      <div class="wg-section">
+        <div class="wg-section-title">Quote</div>
+        <div class="wg-row wg-block-row">
+          <div>
+            <code class="wg-mono">&gt; first line</code><br>
+            <code class="wg-mono">&gt; second line</code>
+          </div>
+          <span>→ green quote block</span>
+        </div>
+        <div class="wg-section-title" style="margin-top:10px">Lists</div>
+        <div class="wg-row"><code class="wg-mono">- item</code>  <span>bullet list</span></div>
+        <div class="wg-row"><code class="wg-mono">1. item</code> <span>numbered list</span></div>
+      </div>
+
+      <div class="wg-section">
+        <div class="wg-section-title">Other</div>
+        <div class="wg-row"><code class="wg-mono">## Heading</code>  <span><strong>Heading</strong></span></div>
+        <div class="wg-row"><code class="wg-mono">@username</code>   <span>mention user</span></div>
+        <div class="wg-row"><code class="wg-mono">---</code>         <span>horizontal rule</span></div>
+        <div class="wg-hint" style="margin-top:8px">
+          Paste a YouTube, Spotify, Twitter or Vimeo URL on its own line to auto-embed it.
+        </div>
+      </div>
+
+    </div>
+  </div>
+</div>
+';
+}, 100);
diff --git a/addons/welcome-guide/nexus-addon.json b/addons/welcome-guide/nexus-addon.json
new file mode 100644
index 0000000..298460d
--- /dev/null
+++ b/addons/welcome-guide/nexus-addon.json
@@ -0,0 +1,8 @@
+{
+  "name":        "Welcome & Formatting Guide",
+  "description": "Adds a collapsible formatting guide to the reply box showing users how to use Markdown, code blocks, quotes, and more.",
+  "version":     "1.0.0",
+  "author":      "Nexus Forum",
+  "hooks":       ["render_post_footer"],
+  "requires":    { "nexus": ">=19" }
+}
diff --git a/addons/welcome-guide/uninstall.php b/addons/welcome-guide/uninstall.php
new file mode 100644
index 0000000..63748d8
--- /dev/null
+++ b/addons/welcome-guide/uninstall.php
@@ -0,0 +1,3 @@
+<?php
+if (!defined('NEXUS')) exit('Forbidden');
+cfg_set('welcome_guide_enabled', '0');
diff --git a/admin/addons.php b/admin/addons.php
new file mode 100644
index 0000000..a7e941b
--- /dev/null
+++ b/admin/addons.php
@@ -0,0 +1,243 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_admin();
+$PAGE_TITLE = 'Addons';
+$ADMIN_PAGE = 'addons';
+$flash = null;
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_ok()) {
+    $action = post('action');
+    $slug   = preg_replace('/[^a-zA-Z0-9_\-]/', '', post('slug'));
+
+    if ($action === 'activate' && $slug) {
+        if (AddonManager::activate($slug)) {
+            $flash = ['ok', "Addon «{$slug}» activated."];
+        } else {
+            $flash = ['err', "Failed to activate «{$slug}». Check error logs."];
+        }
+    } elseif ($action === 'deactivate' && $slug) {
+        AddonManager::deactivate($slug);
+        $flash = ['ok', "Addon «{$slug}» deactivated."];
+    }
+}
+
+$addons = AddonManager::all();
+include __DIR__ . '/../views/partials/admin_layout.php';
+?>
+
+<?php if ($flash): ?>
+  <div class="alert <?= $flash[0] ?>"><?= e($flash[1]) ?></div>
+<?php endif; ?>
+
+<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
+  <div>
+    <h1 style="font-size:1.3rem;font-weight:700;margin:0">🧩 Addons</h1>
+    <p style="color:var(--muted);font-size:14px;margin:4px 0 0">Extend your forum with addons. Place addon folders in <code>addons/</code>.</p>
+  </div>
+  <a href="#developer-docs" class="btn-ghost btn-sm">📖 Developer Docs</a>
+</div>
+
+<?php if (empty($addons)): ?>
+  <div class="acard">
+    <div class="acard-body" style="text-align:center;padding:40px">
+      <div style="font-size:3rem;margin-bottom:12px">🧩</div>
+      <h2 style="font-size:1.1rem;margin-bottom:8px">No addons installed</h2>
+      <p style="color:var(--muted);font-size:14px;margin-bottom:16px">
+        Place addon folders in <code><?= e(ROOT) ?>/addons/</code> and they will appear here.
+      </p>
+      <a href="#developer-docs" class="btn-primary">Create Your First Addon →</a>
+    </div>
+  </div>
+<?php else: ?>
+  <div style="display:flex;flex-direction:column;gap:14px;margin-bottom:28px">
+    <?php foreach ($addons as $slug => $addon): ?>
+      <div class="acard addon-card <?= $addon['active'] ? 'addon-active' : '' ?>">
+        <div class="addon-card-inner">
+          <div class="addon-info">
+            <div class="addon-header">
+              <span class="addon-name"><?= e($addon['name']) ?></span>
+              <span class="addon-version">v<?= e($addon['version']) ?></span>
+              <?php if ($addon['active']): ?>
+                <span class="addon-status-badge active">● Active</span>
+              <?php else: ?>
+                <span class="addon-status-badge inactive">○ Inactive</span>
+              <?php endif; ?>
+            </div>
+            <p class="addon-desc"><?= e($addon['description']) ?></p>
+            <div class="addon-meta">
+              <span>By <strong><?= e($addon['author']) ?></strong></span>
+              <?php if ($addon['url']): ?>
+                <a href="<?= e($addon['url']) ?>" target="_blank" rel="noopener" style="font-size:12px">🔗 Website</a>
+              <?php endif; ?>
+              <span class="addon-slug-tag"><code><?= e($slug) ?></code></span>
+              <?php if (!empty($addon['hooks'])): ?>
+                <span style="font-size:12px;color:var(--muted)">
+                  Hooks: <?= implode(', ', array_map('htmlspecialchars', $addon['hooks'])) ?>
+                </span>
+              <?php endif; ?>
+            </div>
+          </div>
+          <div class="addon-actions">
+            <form method="POST">
+              <?= csrf_input() ?>
+              <input type="hidden" name="slug" value="<?= e($slug) ?>">
+              <?php if ($addon['active']): ?>
+                <input type="hidden" name="action" value="deactivate">
+                <button class="btn-warn btn-sm" onclick="return confirm('Deactivate this addon?')">
+                  ⏸ Deactivate
+                </button>
+              <?php else: ?>
+                <input type="hidden" name="action" value="activate">
+                <button class="btn-ok btn-sm">▶ Activate</button>
+              <?php endif; ?>
+            </form>
+          </div>
+        </div>
+      </div>
+    <?php endforeach; ?>
+  </div>
+<?php endif; ?>
+
+<!-- ── Developer Documentation ─────────────────────── -->
+<div class="acard" id="developer-docs">
+  <div class="acard-head">
+    <h2>📖 Developer Documentation — How to Create an Addon</h2>
+  </div>
+  <div class="acard-body addon-docs">
+
+    <h3>Overview</h3>
+    <p>Addons extend Nexus Forum without modifying core files. Each addon is a folder inside <code>addons/</code> containing at minimum a <code>nexus-addon.json</code> manifest and a <code>main.php</code> entry point.</p>
+
+    <h3>Directory Structure</h3>
+    <pre><code>addons/
+└── your-addon-slug/
+    ├── nexus-addon.json   ← Required: manifest
+    ├── main.php           ← Required: entry point, registers hooks
+    ├── install.php        ← Optional: runs on first activation
+    ├── uninstall.php      ← Optional: runs on deactivation
+    └── assets/            ← Optional: CSS, JS, images</code></pre>
+
+    <h3>1. The Manifest (<code>nexus-addon.json</code>)</h3>
+    <pre><code>{
+  "name":        "My Addon",
+  "description": "A short description of what this addon does.",
+  "version":     "1.0.0",
+  "author":      "Your Name",
+  "url":         "https://yoursite.com/addon",
+  "hooks": [
+    "after_post_saved",
+    "render_post_footer"
+  ],
+  "requires": {
+    "nexus": ">=14"
+  }
+}</code></pre>
+
+    <h3>2. The Entry Point (<code>main.php</code>)</h3>
+    <p>Use <code>addon_on($hook, $callback)</code> to register listeners. Hooks fire in priority order (default 10). Lower number = runs first.</p>
+    <pre><code>&lt;?php
+// main.php — loaded once per request if addon is active
+
+// Example: add a footer line after every post
+addon_on('render_post_footer', function(array $post): string {
+    return '&lt;div class="custom-footer"&gt;Addon says hi on post #' . $post['id'] . '&lt;/div&gt;';
+});
+
+// Example: run code when a new topic is created
+addon_on('after_topic_created', function(array $data): void {
+    // $data has: topic_id, title, slug, category_id, user_id
+    // Do something: call external API, log it, etc.
+    error_log('New topic: ' . $data['title']);
+});
+
+// Example: filter post content before display
+addon_on('render_post_content', function(string $html): string {
+    // Transform HTML before output
+    return str_replace(':-)', '😊', $html);
+});</code></pre>
+
+    <h3>3. Available Hooks</h3>
+    <table class="atable" style="margin-top:8px">
+      <thead><tr><th>Hook</th><th>Receives</th><th>Returns</th><th>When</th></tr></thead>
+      <tbody>
+        <tr><td><code>after_topic_created</code></td><td><code>array</code> topic data</td><td><em>void</em></td><td>After a new topic is saved</td></tr>
+        <tr><td><code>after_reply_saved</code></td><td><code>array</code> post data</td><td><em>void</em></td><td>After a reply is posted</td></tr>
+        <tr><td><code>after_user_registered</code></td><td><code>array</code> user data</td><td><em>void</em></td><td>After a new user registers</td></tr>
+        <tr><td><code>render_post_content</code></td><td><code>string</code> HTML</td><td><code>string</code> HTML</td><td>Before post content is output (filter)</td></tr>
+        <tr><td><code>render_post_footer</code></td><td><code>array</code> post row</td><td><code>string</code> HTML</td><td>Below each post body</td></tr>
+        <tr><td><code>render_topic_header</code></td><td><code>array</code> topic row</td><td><code>string</code> HTML</td><td>Above topic content</td></tr>
+        <tr><td><code>admin_nav_items</code></td><td><code>array</code> nav items</td><td><code>array</code></td><td>Admin sidebar links</td></tr>
+        <tr><td><code>user_karma_changed</code></td><td><code>array</code> {user_id, old, new}</td><td><em>void</em></td><td>When karma is adjusted</td></tr>
+        <tr><td><code>before_page_head</code></td><td><code>string</code> HTML</td><td><code>string</code> HTML</td><td>Inside &lt;head&gt; on every page</td></tr>
+      </tbody>
+    </table>
+
+    <h3>4. Install / Uninstall Scripts</h3>
+    <pre><code>&lt;?php
+// install.php — runs once when admin activates the addon
+// Use this to create tables, insert settings, etc.
+DB::connect()->exec("
+    CREATE TABLE IF NOT EXISTS my_addon_logs (
+        id         INTEGER PRIMARY KEY AUTOINCREMENT,
+        message    TEXT,
+        created_at TEXT DEFAULT CURRENT_TIMESTAMP
+    )
+");
+cfg_set('my_addon_enabled', '1');</code></pre>
+
+    <pre><code>&lt;?php
+// uninstall.php — runs when admin deactivates the addon
+// Clean up tables and settings (optional — you may want to keep data)
+// DB::run("DROP TABLE IF EXISTS my_addon_logs");
+cfg_set('my_addon_enabled', '0');</code></pre>
+
+    <h3>5. Accessing Nexus APIs</h3>
+    <p>All Nexus helpers are available inside addon code:</p>
+    <pre><code>// Database
+$rows = DB::rows("SELECT * FROM topics WHERE category_id=?", [1]);
+DB::insert("INSERT INTO my_addon_logs (message) VALUES (?)", ['hello']);
+
+// Users
+global $USER;  // currently logged-in user (or null)
+
+// Settings
+$siteName = cfg('site_name', 'My Forum');
+cfg_set('my_addon_setting', 'value');
+
+// Notifications
+add_notification($userId, 'my_addon', ['key' => 'value']);
+
+// Karma
+add_karma($userId, 5);  // award 5 karma points
+
+// URL helpers
+$url = u('forum/topic.php?slug=hello');  // respects BASE path</code></pre>
+
+    <h3>6. Example Addon</h3>
+    <p>A complete working example is included at <code>addons/example-hello-world/</code>.</p>
+  </div>
+</div>
+
+<style>
+.addon-card { transition:box-shadow .18s; }
+.addon-card.addon-active { border-color:var(--green); }
+.addon-card-inner { display:flex; align-items:flex-start; gap:16px; justify-content:space-between; flex-wrap:wrap; }
+.addon-info { flex:1; min-width:0; }
+.addon-header { display:flex; align-items:center; gap:10px; margin-bottom:6px; flex-wrap:wrap; }
+.addon-name { font-size:15px; font-weight:700; }
+.addon-version { font-size:11px; background:var(--bg); border:1px solid var(--border); border-radius:8px; padding:1px 7px; color:var(--muted); }
+.addon-status-badge { font-size:11px; font-weight:700; padding:2px 8px; border-radius:8px; }
+.addon-status-badge.active   { background:#f0fdf4; color:#166534; border:1px solid #bbf7d0; }
+.addon-status-badge.inactive { background:#f8fafc; color:#94a3b8; border:1px solid #e2e8f0; }
+.addon-desc { font-size:14px; color:var(--muted); margin:0 0 8px; }
+.addon-meta { display:flex; align-items:center; gap:14px; font-size:13px; color:var(--muted); flex-wrap:wrap; }
+.addon-slug-tag code { background:var(--bg); padding:1px 6px; border-radius:4px; font-size:12px; border:1px solid var(--border); }
+.addon-actions { flex-shrink:0; }
+.addon-docs h3 { font-size:14px; font-weight:700; margin:20px 0 8px; color:var(--text); }
+.addon-docs p  { font-size:14px; color:var(--muted); margin-bottom:10px; line-height:1.7; }
+.addon-docs pre { background:#1e293b; color:#e2e8f0; padding:16px; border-radius:var(--r); overflow-x:auto; margin:8px 0 16px; font-size:13px; line-height:1.6; }
+.addon-docs code { font-family:var(--mono); font-size:13px; }
+.addon-docs p code { background:var(--bg); border:1px solid var(--border); padding:1px 6px; border-radius:4px; }
+</style>
+
+<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?>
diff --git a/admin/categories.php b/admin/categories.php
new file mode 100644
index 0000000..cc5c408
--- /dev/null
+++ b/admin/categories.php
@@ -0,0 +1,234 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_admin();
+$PAGE_TITLE = 'Categories';
+$ADMIN_PAGE = 'categories';
+$flash = null;
+
+// Role permission helper
+function roleSelect(string $name, string $current, string $label): string {
+    $opts = [
+        'guest'     => '🌐 Everyone (guests)',
+        'member'    => '👤 Members only',
+        'moderator' => '🛡️ Moderators+',
+        'admin'     => '👑 Admins only',
+    ];
+    $html = '<div class="fg" style="margin-bottom:8px">'
+          . '<label style="font-size:12px;color:var(--muted)">' . htmlspecialchars($label) . '</label>'
+          . '<select name="' . htmlspecialchars($name) . '" class="fi" style="padding:6px 8px;font-size:13px">';
+    foreach ($opts as $val => $lbl) {
+        $html .= '<option value="' . $val . '"' . ($current === $val ? ' selected' : '') . '>' . $lbl . '</option>';
+    }
+    return $html . '</select></div>';
+}
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_ok()) {
+    $action     = post('action');
+    $validRoles = ['guest', 'member', 'moderator', 'admin'];
+
+    if ($action === 'create') {
+        $name = post('name');
+        if ($name) {
+            $readRole  = in_array(post('read_role'),  $validRoles) ? post('read_role')  : 'guest';
+            $postRole  = in_array(post('post_role'),  $validRoles) ? post('post_role')  : 'member';
+            $replyRole = in_array(post('reply_role'), $validRoles) ? post('reply_role') : 'member';
+            $slug = unique_slug($name, 'categories');
+            DB::insert(
+                'INSERT INTO categories (name,slug,description,color,icon,position,parent_id,read_role,post_role,reply_role)
+                 VALUES (?,?,?,?,?,?,?,?,?,?)',
+                [$name, $slug, post('desc'), post('color', '#3b82f6'), post('icon', '💬'),
+                 (int)post('pos', 99), post('parent_id') ?: null, $readRole, $postRole, $replyRole]
+            );
+            $flash = ['ok', 'Category created!'];
+        }
+    } elseif ($action === 'update') {
+        $readRole  = in_array(post('read_role'),  $validRoles) ? post('read_role')  : 'guest';
+        $postRole  = in_array(post('post_role'),  $validRoles) ? post('post_role')  : 'member';
+        $replyRole = in_array(post('reply_role'), $validRoles) ? post('reply_role') : 'member';
+        DB::run(
+            'UPDATE categories SET name=?,description=?,color=?,icon=?,position=?,read_role=?,post_role=?,reply_role=? WHERE id=?',
+            [post('name'), post('desc'), post('color', '#3b82f6'), post('icon', '💬'),
+             (int)post('pos'), $readRole, $postRole, $replyRole, (int)post('id')]
+        );
+        $flash = ['ok', 'Saved!'];
+    } elseif ($action === 'delete') {
+        $cid = (int)post('id');
+        $tc  = (int)DB::val('SELECT COUNT(*) FROM topics WHERE category_id=?', [$cid]);
+        if ($tc > 0) {
+            $flash = ['err', 'Cannot delete a category that has topics.'];
+        } else {
+            DB::run('DELETE FROM categories WHERE id=?', [$cid]);
+            $flash = ['ok', 'Deleted.'];
+        }
+    }
+}
+
+$cats = DB::rows('SELECT * FROM categories ORDER BY position, id');
+include __DIR__ . '/../views/partials/admin_layout.php';
+?>
+
+<?php if ($flash): ?>
+  <div class="alert <?= $flash[0] ?>"><?= e($flash[1]) ?></div>
+<?php endif; ?>
+
+<div class="admin-two-col">
+
+  <!-- ── Category list ──────────────────────────── -->
+  <div class="acard">
+    <div class="acard-head"><h2>All Categories</h2></div>
+    <table class="atable">
+      <thead>
+        <tr><th>Name</th><th>Permissions</th><th>Topics</th><th>Posts</th><th></th></tr>
+      </thead>
+      <tbody>
+        <?php foreach ($cats as $c): ?>
+          <tr id="cr-<?= $c['id'] ?>">
+            <td>
+              <span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:<?= e($c['color']) ?>;margin-right:6px;vertical-align:middle"></span>
+              <?= e($c['icon']) ?> <?= e($c['name']) ?>
+            </td>
+            <td style="font-size:11px;color:var(--muted)">
+              Read: <strong><?= e($c['read_role'] ?? 'guest') ?></strong> ·
+              Post: <strong><?= e($c['post_role'] ?? 'member') ?></strong> ·
+              Reply: <strong><?= e($c['reply_role'] ?? 'member') ?></strong>
+            </td>
+            <td><?= $c['topic_count'] ?></td>
+            <td><?= $c['post_count'] ?></td>
+            <td style="white-space:nowrap">
+              <button class="btn-ghost btn-sm" onclick="toggleEdit(<?= $c['id'] ?>)">Edit</button>
+              <form method="POST" style="display:inline">
+                <?= csrf_input() ?>
+                <input type="hidden" name="action" value="delete">
+                <input type="hidden" name="id" value="<?= $c['id'] ?>">
+                <button class="btn-danger btn-sm" onclick="return confirm('Delete this category?')">Delete</button>
+              </form>
+            </td>
+          </tr>
+
+          <!-- Inline edit row -->
+          <tr id="ce-<?= $c['id'] ?>" style="display:none;background:#f8fafc">
+            <td colspan="5" style="padding:16px 18px">
+              <form method="POST">
+                <?= csrf_input() ?>
+                <input type="hidden" name="action" value="update">
+                <input type="hidden" name="id" value="<?= $c['id'] ?>">
+
+                <div style="display:grid;grid-template-columns:1fr 60px 80px 70px;gap:10px;margin-bottom:10px">
+                  <div class="fg" style="margin:0">
+                    <label style="font-size:12px;color:var(--muted)">Name</label>
+                    <input type="text" name="name" value="<?= e($c['name']) ?>" class="fi" required>
+                  </div>
+                  <div class="fg" style="margin:0">
+                    <label style="font-size:12px;color:var(--muted)">Icon</label>
+                    <input type="text" name="icon" value="<?= e($c['icon']) ?>" class="fi" style="font-size:1.3rem;text-align:center">
+                  </div>
+                  <div class="fg" style="margin:0">
+                    <label style="font-size:12px;color:var(--muted)">Color</label>
+                    <input type="color" name="color" value="<?= e($c['color']) ?>" class="fi" style="height:40px;padding:2px">
+                  </div>
+                  <div class="fg" style="margin:0">
+                    <label style="font-size:12px;color:var(--muted)">Order</label>
+                    <input type="number" name="pos" value="<?= $c['position'] ?>" class="fi" min="0">
+                  </div>
+                </div>
+
+                <div style="margin-bottom:12px">
+                  <label style="font-size:12px;color:var(--muted)">Description</label>
+                  <input type="text" name="desc" value="<?= e($c['description']) ?>" class="fi" placeholder="Short description">
+                </div>
+
+                <div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:14px;margin-bottom:12px">
+                  <div style="font-size:12px;font-weight:600;color:#64748b;margin-bottom:10px">🔐 Role Permissions</div>
+                  <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px">
+                    <?= roleSelect('read_role',  $c['read_role']  ?? 'guest',  'Who can read') ?>
+                    <?= roleSelect('post_role',  $c['post_role']  ?? 'member', 'Who can post topics') ?>
+                    <?= roleSelect('reply_role', $c['reply_role'] ?? 'member', 'Who can reply') ?>
+                  </div>
+                </div>
+
+                <div style="display:flex;gap:8px;justify-content:flex-end">
+                  <button type="button" class="btn-ghost btn-sm" onclick="toggleEdit(<?= $c['id'] ?>)">Cancel</button>
+                  <button type="submit" class="btn-primary btn-sm">Save Changes</button>
+                </div>
+              </form>
+            </td>
+          </tr>
+        <?php endforeach; ?>
+
+        <?php if (empty($cats)): ?>
+          <tr><td colspan="5" style="text-align:center;padding:24px;color:var(--muted)">No categories yet.</td></tr>
+        <?php endif; ?>
+      </tbody>
+    </table>
+  </div>
+
+  <!-- ── Create category ────────────────────────── -->
+  <div class="acard">
+    <div class="acard-head"><h2>Create Category</h2></div>
+    <div class="acard-body">
+      <form method="POST">
+        <?= csrf_input() ?>
+        <input type="hidden" name="action" value="create">
+
+        <div class="fg">
+          <label>Name <span class="req">*</span></label>
+          <input type="text" name="name" class="fi" required placeholder="Category name">
+        </div>
+
+        <div class="fg">
+          <label>Description</label>
+          <input type="text" name="desc" class="fi" placeholder="Short description (optional)">
+        </div>
+
+        <div style="display:grid;grid-template-columns:1fr 70px 90px 80px;gap:10px;margin-bottom:16px">
+          <div class="fg" style="margin:0">
+            <label>Icon</label>
+            <input type="text" name="icon" class="fi" value="💬" style="font-size:1.3rem;text-align:center">
+          </div>
+          <div class="fg" style="margin:0">
+            <label>Color</label>
+            <input type="color" name="color" class="fi" value="#3b82f6" style="height:40px;padding:2px">
+          </div>
+          <div class="fg" style="margin:0">
+            <label>Position</label>
+            <input type="number" name="pos" class="fi" value="99" min="0">
+          </div>
+        </div>
+
+        <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:14px;margin-bottom:16px">
+          <div style="font-size:13px;font-weight:600;margin-bottom:10px">🔐 Role Permissions</div>
+          <div style="display:grid;grid-template-columns:1fr;gap:8px">
+            <?= roleSelect('read_role',  'guest',  'Who can read this category') ?>
+            <?= roleSelect('post_role',  'member', 'Who can create topics') ?>
+            <?= roleSelect('reply_role', 'member', 'Who can reply to topics') ?>
+          </div>
+        </div>
+
+        <div class="fg">
+          <label>Parent Category <small>(optional)</small></label>
+          <select name="parent_id" class="fi">
+            <option value="">None (top-level)</option>
+            <?php foreach (array_filter($cats, fn($c) => !$c['parent_id']) as $c): ?>
+              <option value="<?= $c['id'] ?>"><?= e($c['icon']) ?> <?= e($c['name']) ?></option>
+            <?php endforeach; ?>
+          </select>
+        </div>
+
+        <button type="submit" class="btn-primary" style="width:100%">Create Category</button>
+      </form>
+    </div>
+  </div>
+
+</div>
+
+<script>
+function toggleEdit(id) {
+  var row  = document.getElementById('cr-' + id);
+  var edit = document.getElementById('ce-' + id);
+  var show = edit.style.display === 'none';
+  edit.style.display = show ? '' : 'none';
+  row.style.opacity  = show ? '0.4' : '1';
+}
+</script>
+
+<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?>
diff --git a/admin/index.php b/admin/index.php
new file mode 100644
index 0000000..3e1a6be
--- /dev/null
+++ b/admin/index.php
@@ -0,0 +1,93 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_admin();
+$PAGE_TITLE = 'Dashboard';
+$ADMIN_PAGE = 'dashboard';
+
+$stats = [
+    'users'  => (int)DB::val('SELECT COUNT(*) FROM users'),
+    'topics' => (int)DB::val('SELECT COUNT(*) FROM topics'),
+    'posts'  => (int)DB::val("SELECT COUNT(*) FROM posts WHERE deleted=0"),
+    'cats'   => (int)DB::val('SELECT COUNT(*) FROM categories'),
+    'u_today'=> (int)DB::val("SELECT COUNT(*) FROM users WHERE DATE(joined_at)=DATE('now')"),
+    't_today'=> (int)DB::val("SELECT COUNT(*) FROM topics WHERE DATE(created_at)=DATE('now')"),
+    'p_today'=> (int)DB::val("SELECT COUNT(*) FROM posts WHERE DATE(created_at)=DATE('now') AND deleted=0"),
+];
+$recent_users  = DB::rows('SELECT * FROM users ORDER BY joined_at DESC LIMIT 6');
+$recent_topics = DB::rows("SELECT t.*,u.username,c.name AS cat FROM topics t JOIN users u ON u.id=t.user_id JOIN categories c ON c.id=t.category_id ORDER BY t.created_at DESC LIMIT 6");
+$activity      = DB::rows("SELECT DATE(created_at) AS day, COUNT(*) AS cnt FROM posts WHERE created_at>=DATE('now','-7 days') AND deleted=0 GROUP BY DATE(created_at) ORDER BY day");
+
+include __DIR__ . '/../views/partials/admin_layout.php';
+?>
+
+<div class="stat-grid">
+  <?php foreach ([
+    ['👥','Total Users',$stats['users'],'+'.  $stats['u_today'].' today','#3b82f6'],
+    ['💬','Topics',     $stats['topics'],'+'. $stats['t_today'].' today','#10b981'],
+    ['📝','Posts',      $stats['posts'],'+'.  $stats['p_today'].' today','#8b5cf6'],
+    ['📂','Categories', $stats['cats'],'','#f59e0b'],
+  ] as [$icon,$label,$num,$sub,$col]): ?>
+    <div class="stat-card">
+      <div class="stat-icon" style="background:<?= $col ?>22;color:<?= $col ?>"><?= $icon ?></div>
+      <div>
+        <div class="stat-num"><?= number_format($num) ?></div>
+        <div class="stat-label"><?= $label ?></div>
+        <?php if ($sub): ?><div class="stat-sub"><?= $sub ?></div><?php endif; ?>
+      </div>
+    </div>
+  <?php endforeach; ?>
+</div>
+
+<div class="acard">
+  <div class="acard-head"><h2>Post Activity — Last 7 Days</h2></div>
+  <div class="acard-body"><canvas id="actChart" height="70"></canvas></div>
+</div>
+
+<div class="admin-two-col">
+  <div class="acard">
+    <div class="acard-head"><h2>Recent Users</h2><a href="<?= u('admin/users.php') ?>" class="btn-ghost btn-sm">All</a></div>
+    <table class="atable">
+      <thead><tr><th>User</th><th>Role</th><th>Joined</th></tr></thead>
+      <tbody>
+        <?php foreach ($recent_users as $u): ?>
+          <tr>
+            <td><a href="<?= u('admin/user.php?id='.$u['id']) ?>">@<?= e($u['username']) ?></a></td>
+            <td><span class="role-tag role-<?= e($u['role']) ?>"><?= e($u['role']) ?></span></td>
+            <td><span class="ago" data-ts="<?= e($u['joined_at']) ?>"></span></td>
+          </tr>
+        <?php endforeach; ?>
+      </tbody>
+    </table>
+  </div>
+  <div class="acard">
+    <div class="acard-head"><h2>Recent Topics</h2><a href="<?= u('admin/topics.php') ?>" class="btn-ghost btn-sm">All</a></div>
+    <table class="atable">
+      <thead><tr><th>Title</th><th>By</th></tr></thead>
+      <tbody>
+        <?php foreach ($recent_topics as $t): ?>
+          <tr>
+            <td><a href="<?= u('forum/topic.php?slug='.urlencode($t['slug'])) ?>" target="_blank"><?= e(mb_substr($t['title'],0,40)) ?></a></td>
+            <td>@<?= e($t['username']) ?></td>
+          </tr>
+        <?php endforeach; ?>
+      </tbody>
+    </table>
+  </div>
+</div>
+
+<script>
+var actData = <?= json_encode($activity) ?>;
+(function(){
+  var s=document.createElement('script');
+  s.src='https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js';
+  s.onload=function(){
+    new Chart(document.getElementById('actChart'),{
+      type:'bar',
+      data:{labels:actData.map(function(d){return d.day;}),datasets:[{label:'Posts',data:actData.map(function(d){return d.cnt;}),backgroundColor:'rgba(59,130,246,.6)',borderColor:'#3b82f6',borderWidth:1,borderRadius:4}]},
+      options:{responsive:true,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{stepSize:1}}}}
+    });
+  };
+  document.head.appendChild(s);
+})();
+</script>
+<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?>
diff --git a/admin/settings.php b/admin/settings.php
new file mode 100644
index 0000000..a35e3ef
--- /dev/null
+++ b/admin/settings.php
@@ -0,0 +1,159 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_admin_only();
+$PAGE_TITLE = 'Settings';
+$ADMIN_PAGE = 'settings';
+$saved = false;
+
+if ($_SERVER['REQUEST_METHOD']==='POST' && csrf_ok()) {
+    // Checkboxes (default 0 if unchecked)
+    $checkboxes = ['allow_reg','rate_limit_enabled','post_captcha_enabled','topic_captcha_enabled','post_layout_horizontal'];
+    $textfields  = ['site_name','site_desc','logo_url','topics_per_page','posts_per_page',
+                    'footer_text','custom_head','rate_limit_count','rate_limit_window',
+                    'max_upload_mb'];
+    foreach ($checkboxes as $k) cfg_set($k, isset($_POST[$k]) ? '1' : '0');
+    foreach ($textfields  as $k) cfg_set($k, post($k));
+    $saved = true;
+}
+
+$s = [];
+foreach (DB::rows('SELECT key,value FROM settings') as $r) $s[$r['key']] = $r['value'];
+include __DIR__ . '/../views/partials/admin_layout.php';
+?>
+<?php if ($saved): ?><div class="alert ok">✅ Settings saved!</div><?php endif; ?>
+
+<form method="POST">
+  <?= csrf_input() ?>
+
+  <!-- Site Identity -->
+  <div class="acard" style="margin-bottom:18px">
+    <div class="acard-head"><h2>🏠 Site Identity</h2></div>
+    <div class="acard-body">
+      <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
+        <div class="fg"><label>Site Name</label>
+          <input type="text" name="site_name" class="fi" value="<?= e($s['site_name']??'Nexus Forum') ?>"></div>
+        <div class="fg"><label>Logo URL <small>(blank = text logo)</small></label>
+          <input type="text" name="logo_url" class="fi" value="<?= e($s['logo_url']??'') ?>" placeholder="/public/uploads/logo.png"></div>
+        <div class="fg" style="grid-column:1/-1"><label>Description</label>
+          <textarea name="site_desc" class="fi" rows="2"><?= e($s['site_desc']??'') ?></textarea></div>
+        <div class="fg" style="grid-column:1/-1"><label>Footer Text <small>(HTML allowed)</small></label>
+          <input type="text" name="footer_text" class="fi" value="<?= e($s['footer_text']??'') ?>"></div>
+      </div>
+    </div>
+  </div>
+
+  <!-- Registration -->
+  <div class="acard" style="margin-bottom:18px">
+    <div class="acard-head"><h2>👥 Registration</h2></div>
+    <div class="acard-body">
+      <label class="setting-toggle">
+        <div><strong>Allow Registration</strong><p class="hint">Let new users create accounts</p></div>
+        <label class="toggle-sw"><input type="checkbox" name="allow_reg" <?= ($s['allow_reg']??'1')==='1'?'checked':'' ?>><span class="toggle-knob"></span></label>
+      </label>
+    </div>
+  </div>
+
+  <!-- Content -->
+  <div class="acard" style="margin-bottom:18px">
+    <div class="acard-head"><h2>📄 Pagination</h2></div>
+    <div class="acard-body">
+      <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
+        <div class="fg"><label>Topics Per Page</label>
+          <input type="number" name="topics_per_page" class="fi" value="<?= e($s['topics_per_page']??'30') ?>" min="5" max="100"></div>
+        <div class="fg"><label>Posts Per Page</label>
+          <input type="number" name="posts_per_page" class="fi" value="<?= e($s['posts_per_page']??'20') ?>" min="5" max="100"></div>
+
+        <div class="arow">
+          <div>
+            <label class="form-label">Max Image Upload Size (MB)</label>
+            <small style="color:var(--muted);display:block">Maximum file size per uploaded image. Range: 1–50 MB.</small>
+          </div>
+          <input type="number" name="max_upload_mb" class="fi" value="<?= e($s['max_upload_mb']??'5') ?>" min="1" max="50" style="width:80px"></div>
+      </div>
+    </div>
+  </div>
+
+  <!-- Rate Limiting -->
+  <div class="acard" style="margin-bottom:18px">
+    <div class="acard-head"><h2>⏱️ Post Rate Limiting</h2></div>
+    <div class="acard-body">
+      <label class="setting-toggle" style="margin-bottom:16px">
+        <div><strong>Enable Rate Limiting</strong><p class="hint">Prevent users from posting too fast. Admins &amp; moderators are exempt.</p></div>
+        <label class="toggle-sw"><input type="checkbox" name="rate_limit_enabled" id="rlToggle"
+          <?= ($s['rate_limit_enabled']??'0')==='1'?'checked':'' ?>
+          onchange="document.getElementById('rlSettings').style.display=this.checked?'':'none'">
+          <span class="toggle-knob"></span></label>
+      </label>
+      <div id="rlSettings" style="<?= ($s['rate_limit_enabled']??'0')==='1'?'':'display:none' ?>;background:#f8fafc;border:1px solid var(--border);border-radius:8px;padding:16px">
+        <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
+          <div class="fg">
+            <label>Max Posts Per Window</label>
+            <input type="number" name="rate_limit_count" class="fi" value="<?= e($s['rate_limit_count']??'3') ?>" min="1" max="100">
+            <span class="hint">Number of posts allowed in the time window</span>
+          </div>
+          <div class="fg">
+            <label>Time Window (seconds)</label>
+            <input type="number" name="rate_limit_window" class="fi" value="<?= e($s['rate_limit_window']??'60') ?>" min="5" max="86400">
+            <span class="hint">E.g. 60 = 1 minute, 3600 = 1 hour</span>
+          </div>
+        </div>
+        <div class="rate-presets">
+          <span style="font-size:12px;color:#94a3b8;margin-right:6px">Presets:</span>
+          <button type="button" class="btn-ghost btn-sm" onclick="setRL(3,60)">Relaxed (3/min)</button>
+          <button type="button" class="btn-ghost btn-sm" onclick="setRL(1,30)">Moderate (1/30s)</button>
+          <button type="button" class="btn-ghost btn-sm" onclick="setRL(1,120)">Strict (1/2min)</button>
+          <button type="button" class="btn-ghost btn-sm" onclick="setRL(5,300)">5 per 5 mins</button>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- Post/Reply Captcha -->
+  <div class="acard" style="margin-bottom:18px">
+    <div class="acard-head"><h2>🔒 Post/Reply Security Captcha</h2></div>
+    <div class="acard-body">
+      <label class="setting-toggle" style="margin-bottom:12px">
+        <div>
+          <strong>Require Math Captcha to Reply</strong>
+          <p class="hint">Users must solve a simple math question before posting a reply to any topic</p>
+        </div>
+        <label class="toggle-sw"><input type="checkbox" name="post_captcha_enabled"
+          <?= ($s['post_captcha_enabled']??'0')==='1'?'checked':'' ?>><span class="toggle-knob"></span></label>
+      </label>
+      <label class="setting-toggle">
+        <div>
+          <strong>Require Math Captcha to Create Topic</strong>
+          <p class="hint">Users must solve a math question when creating a new topic</p>
+        </div>
+        <label class="toggle-sw"><input type="checkbox" name="topic_captcha_enabled"
+          <?= ($s['topic_captcha_enabled']??'0')==='1'?'checked':'' ?>><span class="toggle-knob"></span></label>
+      </label>
+    </div>
+  </div>
+
+  <!-- Advanced -->
+  <div class="acard" style="margin-bottom:18px">
+    <div class="acard-head"><h2>🔧 Advanced</h2></div>
+    <div class="acard-body">
+      <div class="fg">
+        <label>Custom &lt;head&gt; HTML <small>(analytics, meta tags)</small></label>
+        <textarea name="custom_head" class="fi" rows="4" style="font-family:var(--mono);font-size:13px"
+                  placeholder="<!-- Google Analytics, custom meta, etc. -->"><?= e($s['custom_head']??'') ?></textarea>
+      </div>
+    </div>
+  </div>
+
+  <button type="submit" class="btn-primary btn-lg">💾 Save Settings</button>
+</form>
+
+<style>
+.setting-toggle { display:flex; align-items:center; justify-content:space-between; padding:6px 0; }
+.rate-presets { display:flex; flex-wrap:wrap; gap:6px; margin-top:12px; align-items:center; }
+</style>
+<script>
+function setRL(count,window){
+  document.querySelector('[name=rate_limit_count]').value  = count;
+  document.querySelector('[name=rate_limit_window]').value = window;
+}
+</script>
+<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?>
diff --git a/admin/themes.php b/admin/themes.php
new file mode 100644
index 0000000..5952872
--- /dev/null
+++ b/admin/themes.php
@@ -0,0 +1,276 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_admin_only();
+$PAGE_TITLE = 'Themes & Templates';
+$ADMIN_PAGE = 'themes';
+$flash = null;
+
+if ($_SERVER['REQUEST_METHOD']==='POST' && csrf_ok()) {
+    $action = post('action');
+
+    if ($action === 'create' || $action === 'update') {
+        $name = post('name');
+        $css  = post('css');
+        if ($name) {
+            if ($action === 'create') {
+                $slug = unique_slug($name, 'themes');
+                DB::insert('INSERT INTO themes (name,slug,css) VALUES (?,?,?)', [$name,$slug,$css]);
+                $flash = ['ok','Theme created!'];
+            } else {
+                $tid = (int)post('id');
+                DB::run('UPDATE themes SET name=?,css=? WHERE id=?', [$name,$css,$tid]);
+                $flash = ['ok','Theme updated!'];
+            }
+        }
+    } elseif ($action === 'activate') {
+        $tid = (int)post('id');
+        DB::run('UPDATE themes SET is_active=0');
+        DB::run('UPDATE themes SET is_active=1 WHERE id=?', [$tid]);
+        $flash = ['ok','Theme activated!'];
+    } elseif ($action === 'deactivate') {
+        DB::run('UPDATE themes SET is_active=0');
+        $flash = ['ok','Default theme restored.'];
+    } elseif ($action === 'delete') {
+        $tid = (int)post('id');
+        DB::run('DELETE FROM themes WHERE id=?', [$tid]);
+        $flash = ['ok','Theme deleted.'];
+    }
+}
+
+$themes = DB::rows('SELECT * FROM themes ORDER BY id');
+$editId = (int)get('edit');
+$editTheme = $editId ? DB::row('SELECT * FROM themes WHERE id=?', [$editId]) : null;
+
+include __DIR__ . '/../views/partials/admin_layout.php';
+?>
+
+<?php if ($flash): ?>
+  <div class="alert <?= $flash[0] ?>"><?= e($flash[1]) ?></div>
+<?php endif; ?>
+
+<div class="admin-two-col">
+  <!-- Theme List -->
+  <div class="acard">
+    <div class="acard-head">
+      <h2>🎨 Themes</h2>
+      <span style="font-size:12px;color:#94a3b8">One theme active at a time</span>
+    </div>
+    <div class="acard-body" style="padding:0">
+      <?php if (empty($themes)): ?>
+        <p style="padding:20px;color:#94a3b8;text-align:center">No themes yet. Create one below.</p>
+      <?php else: ?>
+        <?php foreach ($themes as $th): ?>
+          <div class="theme-row <?= $th['is_active'] ? 'theme-active' : '' ?>">
+            <div class="theme-info">
+              <?php if ($th['is_active']): ?>
+                <span class="stag active" style="margin-right:8px">● Active</span>
+              <?php endif; ?>
+              <strong><?= e($th['name']) ?></strong>
+              <span style="font-size:12px;color:#94a3b8;margin-left:8px"><?= strlen($th['css']) ?> chars CSS</span>
+            </div>
+            <div class="theme-btns">
+              <a href="?edit=<?= $th['id'] ?>" class="btn-ghost btn-sm">Edit</a>
+              <?php if (!$th['is_active']): ?>
+                <form method="POST" style="display:inline"><?= csrf_input() ?>
+                  <input type="hidden" name="action" value="activate">
+                  <input type="hidden" name="id" value="<?= $th['id'] ?>">
+                  <button class="btn-primary btn-sm">Activate</button>
+                </form>
+              <?php else: ?>
+                <form method="POST" style="display:inline"><?= csrf_input() ?>
+                  <input type="hidden" name="action" value="deactivate">
+                  <button class="btn-ghost btn-sm">Deactivate</button>
+                </form>
+              <?php endif; ?>
+              <form method="POST" style="display:inline"><?= csrf_input() ?>
+                <input type="hidden" name="action" value="delete">
+                <input type="hidden" name="id" value="<?= $th['id'] ?>">
+                <button class="btn-danger btn-sm" onclick="return confirm('Delete theme?')">Delete</button>
+              </form>
+            </div>
+          </div>
+        <?php endforeach; ?>
+      <?php endif; ?>
+    </div>
+  </div>
+
+  <!-- Create / Edit -->
+  <div class="acard">
+    <div class="acard-head">
+      <h2><?= $editTheme ? '✏️ Edit: '.e($editTheme['name']) : '+ New Theme' ?></h2>
+      <?php if ($editTheme): ?><a href="<?= u('admin/themes.php') ?>" class="btn-ghost btn-sm">Cancel</a><?php endif; ?>
+    </div>
+    <div class="acard-body">
+      <form method="POST">
+        <?= csrf_input() ?>
+        <input type="hidden" name="action" value="<?= $editTheme ? 'update' : 'create' ?>">
+        <?php if ($editTheme): ?>
+          <input type="hidden" name="id" value="<?= $editTheme['id'] ?>">
+        <?php endif; ?>
+
+        <div class="fg">
+          <label>Theme Name</label>
+          <input type="text" name="name" class="fi" required placeholder="My Custom Theme"
+                 value="<?= e($editTheme['name'] ?? '') ?>">
+        </div>
+
+        <div class="fg">
+          <label>Custom CSS</label>
+          <div class="css-editor-wrap">
+            <div class="css-presets">
+              <span style="font-size:12px;color:#94a3b8;margin-right:6px">Presets:</span>
+              <button type="button" class="btn-ghost btn-sm" onclick="applyPreset('dark')">🌙 Dark</button>
+              <button type="button" class="btn-ghost btn-sm" onclick="applyPreset('warm')">🍂 Warm</button>
+              <button type="button" class="btn-ghost btn-sm" onclick="applyPreset('green')">🌿 Green</button>
+              <button type="button" class="btn-ghost btn-sm" onclick="applyPreset('purple')">💜 Purple</button>
+            </div>
+            <textarea name="css" id="cssEditor" class="fi css-ta" rows="18" spellcheck="false"
+                      placeholder="/* Write your custom CSS here */
+/* Override any CSS variables or rules */
+
+/* Example: Change primary color */
+:root {
+  --blue: #e74c3c;
+  --blue-d: #c0392b;
+  --blue-l: #fdf2f2;
+}
+
+/* Example: Custom background */
+body {
+  background: #1a1a2e;
+  color: #e0e0e0;
+}"><?= e($editTheme['css'] ?? '') ?></textarea>
+          </div>
+        </div>
+
+        <div style="display:flex;gap:10px;align-items:center">
+          <button type="submit" class="btn-primary">💾 Save Theme</button>
+          <button type="button" class="btn-ghost" onclick="previewTheme()">👁️ Preview</button>
+          <span style="font-size:12px;color:#94a3b8">Preview opens forum in new tab</span>
+        </div>
+      </form>
+    </div>
+  </div>
+</div>
+
+<!-- CSS Variable Reference -->
+<div class="acard">
+  <div class="acard-head"><h2>📖 CSS Variables Reference</h2></div>
+  <div class="acard-body">
+    <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;font-size:13px">
+      <?php
+      $vars = [
+        ['--blue','Primary color (links, buttons)','#3b82f6'],
+        ['--blue-d','Primary hover color','#2563eb'],
+        ['--blue-l','Primary light background','#eff6ff'],
+        ['--bg','Page background','#f1f5f9'],
+        ['--surface','Card/panel background','#ffffff'],
+        ['--border','Border color','#e2e8f0'],
+        ['--text','Main text color','#0f172a'],
+        ['--muted','Secondary text','#64748b'],
+        ['--green','Success/accent color','#22c55e'],
+        ['--red','Danger color','#ef4444'],
+        ['--amber','Warning color','#f59e0b'],
+        ['--header','Header height','56px'],
+        ['--sidebar','Sidebar width','210px'],
+        ['--font','Font family','Inter'],
+        ['--mono','Monospace font','JetBrains Mono'],
+        ['--r','Border radius','8px'],
+      ];
+      foreach ($vars as [$var,$desc,$val]): ?>
+        <div style="background:#f8fafc;padding:8px 10px;border-radius:6px;border:1px solid #e2e8f0">
+          <code style="color:#6366f1;font-size:12px"><?= e($var) ?></code>
+          <div style="font-size:11px;color:#94a3b8;margin-top:2px"><?= e($desc) ?></div>
+          <div style="font-size:11px;color:#64748b">Default: <em><?= e($val) ?></em></div>
+        </div>
+      <?php endforeach; ?>
+    </div>
+  </div>
+</div>
+
+<style>
+.theme-row{display:flex;align-items:center;justify-content:space-between;padding:12px 18px;border-bottom:1px solid #f1f5f9;transition:background .18s}
+.theme-row:hover{background:#f8fafc}
+.theme-row:last-child{border-bottom:none}
+.theme-active{background:#eff6ff}
+.theme-info{display:flex;align-items:center;flex:1}
+.theme-btns{display:flex;gap:6px;align-items:center}
+.css-editor-wrap{background:#1e293b;border-radius:8px;overflow:hidden}
+.css-presets{display:flex;align-items:center;gap:4px;padding:8px 12px;background:#0f172a}
+.css-ta{background:#1e293b!important;color:#e2e8f0!important;font-family:var(--mono)!important;font-size:13px!important;border-radius:0!important;border:none!important;padding:14px 16px!important;min-height:300px;line-height:1.6}
+.css-ta:focus{box-shadow:none!important}
+</style>
+
+<script>
+var presets = {
+  dark: `:root {
+  --blue: #6366f1;
+  --blue-d: #4f46e5;
+  --blue-l: #1e1b4b;
+  --bg: #0f172a;
+  --surface: #1e293b;
+  --border: #334155;
+  --border-l: #1e293b;
+  --text: #f1f5f9;
+  --muted: #94a3b8;
+  --faint: #64748b;
+}
+.site-header { background: #1e293b; border-color: #334155; }
+.sidebar { background: #1e293b; border-color: #334155; }
+.site-footer { background: #1e293b; border-color: #334155; }
+.cat-card, .topic-row, .post { background: #1e293b; border-color: #334155; }
+.reply-box { background: #1e293b; border-color: #334155; }
+.tl-hdr { background: #0f172a; }
+.topic-list { background: #1e293b; border-color: #334155; }`,
+
+  warm: `:root {
+  --blue: #ea580c;
+  --blue-d: #c2410c;
+  --blue-l: #fff7ed;
+  --bg: #fdf4ec;
+  --surface: #fffaf5;
+  --border: #fed7aa;
+  --text: #431407;
+  --muted: #9a3412;
+}`,
+
+  green: `:root {
+  --blue: #16a34a;
+  --blue-d: #15803d;
+  --blue-l: #f0fdf4;
+  --bg: #f0fdf4;
+  --surface: #ffffff;
+  --border: #bbf7d0;
+  --text: #14532d;
+  --muted: #166534;
+}`,
+
+  purple: `:root {
+  --blue: #9333ea;
+  --blue-d: #7e22ce;
+  --blue-l: #faf5ff;
+  --bg: #faf5ff;
+  --surface: #ffffff;
+  --border: #e9d5ff;
+  --text: #3b0764;
+  --muted: #6b21a8;
+}`
+};
+
+function applyPreset(name) {
+  document.getElementById('cssEditor').value = presets[name] || '';
+}
+
+function previewTheme() {
+  var css = document.getElementById('cssEditor').value;
+  var key = 'nx_preview_css';
+  localStorage.setItem(key, css);
+  var w = window.open('<?= u('/') ?>', '_blank');
+  w.addEventListener('load', function() {
+    w.postMessage({ type: 'nx_preview', css: css }, '*');
+  });
+  alert('Preview tip: the theme preview uses postMessage.\nActivate the theme to see it properly in all browsers.');
+}
+</script>
+
+<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?>
diff --git a/admin/topics.php b/admin/topics.php
new file mode 100644
index 0000000..623f97f
--- /dev/null
+++ b/admin/topics.php
@@ -0,0 +1,56 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_admin();
+$PAGE_TITLE = 'Topics';
+$ADMIN_PAGE = 'topics';
+$q    = get('q');
+$page = max(1,(int)get('page','1'));
+$pp   = 30; $off = ($page-1)*$pp;
+$like = '%'.$q.'%';
+$where = $q ? "WHERE t.title LIKE ?" : "";
+$params = $q ? [$like] : [];
+$topics = DB::rows("SELECT t.*,u.username,c.name AS cat FROM topics t JOIN users u ON u.id=t.user_id JOIN categories c ON c.id=t.category_id $where ORDER BY t.created_at DESC LIMIT $pp OFFSET $off", $params);
+$total = (int)DB::val("SELECT COUNT(*) FROM topics t $where", $params);
+$pages = max(1,(int)ceil($total/$pp));
+include __DIR__ . '/../views/partials/admin_layout.php';
+?>
+<div class="admin-toolbar">
+  <form method="GET" class="at-form">
+    <input type="text" name="q" value="<?= e($q) ?>" placeholder="Search topics…" class="fi at-fi">
+    <button type="submit" class="btn-primary">Search</button>
+    <?php if ($q): ?><a href="<?= u('admin/topics.php') ?>" class="btn-ghost">Clear</a><?php endif; ?>
+  </form>
+</div>
+<div class="acard">
+  <table class="atable">
+    <thead><tr><th>Title</th><th>Category</th><th>By</th><th>Status</th><th>Actions</th></tr></thead>
+    <tbody>
+      <?php foreach ($topics as $t): ?>
+        <tr>
+          <td><a href="<?= u('forum/topic.php?slug='.urlencode($t['slug'])) ?>" target="_blank"><?= e(mb_substr($t['title'],0,50)) ?></a></td>
+          <td><?= e($t['cat']) ?></td>
+          <td>@<?= e($t['username']) ?></td>
+          <td><?= $t['pinned']?'📌':'' ?><?= $t['closed']?'🔒':'' ?><?= $t['archived']?'📦':'' ?><?= (!$t['pinned']&&!$t['closed']&&!$t['archived'])?'Active':'' ?></td>
+          <td>
+            <?php foreach ([['pin','unpin','📌'],['close','open','🔒'],['archive','unarchive','📦']] as [$a,$b,$icon]): ?>
+              <form method="POST" action="<?= u('api/topic_action.php') ?>" style="display:inline">
+                <?= csrf_input() ?><input type="hidden" name="id" value="<?= $t['id'] ?>">
+                <input type="hidden" name="action" value="<?= $t[$a==='pin'?'pinned':($a==='close'?'closed':'archived')] ? $b : $a ?>">
+                <button class="btn-ghost btn-sm" title="<?= $a ?>"><?= $icon ?></button>
+              </form>
+            <?php endforeach; ?>
+          </td>
+        </tr>
+      <?php endforeach; ?>
+      <?php if (!$topics): ?><tr><td colspan="5" style="text-align:center;color:#6b7280;padding:2rem">No topics.</td></tr><?php endif; ?>
+    </tbody>
+  </table>
+</div>
+<?php if ($pages>1): ?>
+  <nav class="pager">
+    <?php for($i=1;$i<=$pages;$i++): ?>
+      <a href="?page=<?= $i ?>&q=<?= urlencode($q) ?>" class="pg-btn <?= $i===$page?'active':'' ?>"><?= $i ?></a>
+    <?php endfor; ?>
+  </nav>
+<?php endif; ?>
+<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?>
diff --git a/admin/user.php b/admin/user.php
new file mode 100644
index 0000000..e03ddb6
--- /dev/null
+++ b/admin/user.php
@@ -0,0 +1,327 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_admin();
+$id   = (int)get('id');
+$user = DB::row('SELECT * FROM users WHERE id=?',[$id]);
+if (!$user) render_404();
+$flash = null; $newpw = null;
+
+// All available permissions
+$ALL_PERMS = [
+    'pin_topics'     => 'Pin/Unpin Topics',
+    'close_topics'   => 'Close/Open Topics',
+    'delete_posts'   => 'Delete Any Post',
+    'edit_posts'     => 'Edit Any Post',
+    'manage_users'   => 'View User List',
+    'upload_images'  => 'Upload Images',
+    'bypass_silence' => 'Bypass Silence',
+];
+
+if ($_SERVER['REQUEST_METHOD']==='POST' && csrf_ok()) {
+    $action = post('action');
+
+    if ($action === 'adjust_karma') {
+        $amount  = (int)post('karma_amount');
+        $op      = post('karma_op'); // add | subtract | set
+        $reason  = sanitise(post('karma_reason'));
+        if (!in_array($op, ['add','subtract','set'])) $op = 'add';
+        if ($amount < 0) $amount = 0;
+        $oldKarma = (int)$user['karma'];
+        if ($op === 'set') {
+            $newKarma = $amount;
+            DB::run('UPDATE users SET karma=? WHERE id=?', [$newKarma, $id]);
+        } elseif ($op === 'add') {
+            $newKarma = $oldKarma + $amount;
+            DB::run('UPDATE users SET karma=karma+? WHERE id=?', [$amount, $id]);
+        } else {
+            $newKarma = max(0, $oldKarma - $amount);
+            DB::run('UPDATE users SET karma=? WHERE id=?', [$newKarma, $id]);
+        }
+        if ($reason) {
+            add_notification($id, 'karma_admin', [
+                'from'   => $USER['username'],
+                'change' => $newKarma - $oldKarma,
+                'new'    => $newKarma,
+                'reason' => $reason,
+            ]);
+        }
+        // Fire addon hook
+        addon_hook('user_karma_changed', ['user_id'=>$id,'old'=>$oldKarma,'new'=>$newKarma,'by'=>$USER['id']]);
+        $flash = ['ok', "Karma updated: {$oldKarma} → {$newKarma}" . ($reason ? " ({$reason})" : '')];
+        $user  = DB::row('SELECT * FROM users WHERE id=?', [$id]); // refresh
+    }
+
+    if ($action === 'edit_profile') {
+        // Admin editing another user's profile details
+        $newUsername = trim(post('username'));
+        $newEmail    = trim(post('email'));
+        $newBio      = trim(post('bio'));
+        $newRole     = post('role');
+
+        $errs = [];
+        if (strlen($newUsername)<3) $errs[]='Username too short.';
+        if (!filter_var($newEmail, FILTER_VALIDATE_EMAIL)) $errs[]='Invalid email.';
+        if (!in_array($newRole,['member','moderator','admin'])) $newRole='member';
+
+        // Check uniqueness (excluding current user)
+        if (!$errs) {
+            $dup = DB::row('SELECT id FROM users WHERE (username=? OR email=?) AND id!=?',[$newUsername,$newEmail,$id]);
+            if ($dup) $errs[]='Username or email already taken.';
+        }
+
+        if (!$errs) {
+            // Build permissions array for moderators
+            $permsArr = [];
+            if ($newRole === 'moderator' || $newRole === 'admin') {
+                foreach (array_keys($ALL_PERMS) as $pkey) {
+                    if (!empty($_POST['perm_'.$pkey])) $permsArr[$pkey] = true;
+                }
+            }
+            DB::run('UPDATE users SET username=?,email=?,bio=?,role=?,permissions=? WHERE id=?',
+                [$newUsername,$newEmail,$newBio,$newRole,json_encode($permsArr),$id]);
+
+            // Handle new password
+            $np = post('new_password');
+            if ($np) {
+                if (strlen($np)<8) { $flash='Password too short (8+ chars).'; }
+                else {
+                    DB::run('UPDATE users SET password=? WHERE id=?',[password_hash($np,PASSWORD_BCRYPT,['cost'=>12]),$id]);
+                }
+            }
+            if (!$flash) { $flash='User profile updated!'; $user=DB::row('SELECT * FROM users WHERE id=?',[$id]); }
+        } else {
+            $flash = implode(' ', $errs);
+        }
+    }
+    elseif ($id !== $USER['id']) {
+        switch ($action) {
+            case 'suspend':   DB::run('UPDATE users SET suspended=1 WHERE id=?',[$id]); $flash='User suspended.'; break;
+            case 'unsuspend': DB::run('UPDATE users SET suspended=0 WHERE id=?',[$id]); $flash='User unsuspended.'; break;
+            case 'silence':   DB::run('UPDATE users SET silenced=1  WHERE id=?',[$id]); $flash='User silenced.'; break;
+            case 'unsilence': DB::run('UPDATE users SET silenced=0  WHERE id=?',[$id]); $flash='User unsilenced.'; break;
+            case 'resetpw':
+                $newpw = bin2hex(random_bytes(5));
+                DB::run('UPDATE users SET password=? WHERE id=?',[password_hash($newpw,PASSWORD_BCRYPT,['cost'=>12]),$id]);
+                break;
+        }
+        $user = DB::row('SELECT * FROM users WHERE id=?',[$id]);
+    }
+}
+
+$userPerms = json_decode($user['permissions'] ?? '{}', true) ?: [];
+$utopics = DB::rows("SELECT t.*,c.name AS cat FROM topics t JOIN categories c ON c.id=t.category_id WHERE t.user_id=? ORDER BY t.created_at DESC LIMIT 10",[$id]);
+$uposts  = DB::rows("SELECT p.*,t.title AS tt,t.slug AS ts FROM posts p JOIN topics t ON t.id=p.topic_id WHERE p.user_id=? AND p.deleted=0 ORDER BY p.created_at DESC LIMIT 10",[$id]);
+
+$PAGE_TITLE = 'User: @'.$user['username'];
+$ADMIN_PAGE = 'users';
+include __DIR__ . '/../views/partials/admin_layout.php';
+?>
+<p style="margin-bottom:16px"><a href="<?= u('admin/users.php') ?>">← Back to Users</a></p>
+
+<?php if ($flash): ?>
+  <div class="alert <?= strpos($flash,'!') ? 'ok' : 'err' ?>"><?= e($flash) ?></div>
+<?php endif; ?>
+<?php if ($newpw): ?>
+  <div class="alert warn" style="font-size:14px">🔑 Password reset! New password: <strong style="font-family:monospace"><?= e($newpw) ?></strong> — share this securely with the user.</div>
+<?php endif; ?>
+
+<div class="admin-two-col" style="margin-bottom:0">
+  <!-- Edit Profile Card -->
+  <div class="acard">
+    <div class="acard-head"><h2>✏️ Edit Profile</h2></div>
+    <div class="acard-body">
+      <form method="POST">
+        <?= csrf_input() ?>
+        <input type="hidden" name="action" value="edit_profile">
+
+        <div class="fg">
+          <label>Username</label>
+          <input type="text" name="username" class="fi" required value="<?= e($user['username']) ?>" minlength="3" maxlength="30">
+        </div>
+        <div class="fg">
+          <label>Email</label>
+          <input type="email" name="email" class="fi" required value="<?= e($user['email']) ?>">
+        </div>
+        <div class="fg">
+          <label>Bio</label>
+          <textarea name="bio" class="fi" rows="3" maxlength="500"><?= e($user['bio']??'') ?></textarea>
+        </div>
+        <div class="fg">
+          <label>Role</label>
+          <select name="role" class="fi" id="roleSelect" onchange="togglePerms(this.value)" <?= $id===$USER['id']?'disabled':'' ?>>
+            <option value="member"    <?= $user['role']==='member'    ?'selected':'' ?>>Member</option>
+            <option value="moderator" <?= $user['role']==='moderator' ?'selected':'' ?>>Moderator</option>
+            <option value="admin"     <?= $user['role']==='admin'     ?'selected':'' ?>>Admin</option>
+          </select>
+        </div>
+
+        <!-- Permissions (shown for moderator) -->
+        <div id="permsBox" style="<?= in_array($user['role'],['moderator','admin'])?'':'display:none' ?>">
+          <div class="fg">
+            <label>Moderator Permissions</label>
+            <div class="perms-grid">
+              <?php foreach ($ALL_PERMS as $pk => $plabel): ?>
+                <label class="perm-chk">
+                  <input type="checkbox" name="perm_<?= $pk ?>" value="1"
+                    <?= !empty($userPerms[$pk]) ? 'checked' : '' ?>>
+                  <?= e($plabel) ?>
+                </label>
+              <?php endforeach; ?>
+            </div>
+          </div>
+        </div>
+
+        <div class="fg">
+          <label>Set New Password <small>(leave blank to keep)</small></label>
+          <input type="text" name="new_password" class="fi" placeholder="New password (8+ chars)" autocomplete="new-password">
+        </div>
+
+        <button type="submit" class="btn-primary">Save Changes</button>
+      </form>
+    </div>
+  </div>
+
+  <!-- Quick Actions Card -->
+  <div>
+    <div class="acard" style="margin-bottom:16px">
+      <div class="acard-head"><h2>👤 User Info</h2></div>
+      <div class="acard-body">
+        <div style="display:flex;align-items:center;gap:14px;margin-bottom:16px">
+          <?php if ($user['avatar']): ?>
+            <img src="<?= e($user['avatar']) ?>" class="av-xl" alt="">
+          <?php else: ?>
+            <span class="av-xl av-init"><?= strtoupper($user['username'][0]) ?></span>
+          <?php endif; ?>
+          <div>
+            <h3 style="margin-bottom:4px">@<?= e($user['username']) ?></h3>
+            <p style="font-size:13px;color:#64748b;margin-bottom:6px"><?= e($user['email']) ?></p>
+            <span class="role-tag role-<?= e($user['role']) ?>"><?= e($user['role']) ?></span>
+            <?php if ($user['suspended']): ?> <span class="stag suspended">Suspended</span><?php endif; ?>
+          </div>
+        </div>
+        <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:13px">
+          <div>📝 Posts: <strong><?= $user['post_count'] ?></strong></div>
+          <div>💬 Topics: <strong><?= $user['topic_count'] ?></strong></div>
+          <div>⭐ Karma: <strong><?= $user['karma'] ?></strong></div>
+          <div>📅 Joined: <span class="ago" data-ts="<?= e($user['joined_at']) ?>"></span></div>
+        </div>
+        <a href="<?= u('users/profile.php?u='.urlencode($user['username'])) ?>" target="_blank" class="btn-ghost btn-sm" style="margin-top:12px">View Profile →</a>
+      </div>
+    </div>
+
+    <?php if ($id !== $USER['id']): ?>
+    <div class="acard" style="margin-bottom:16px">
+      <div class="acard-head"><h2>⭐ Karma Management</h2>
+        <?php $kTier = karma_tier((int)$user['karma']); ?>
+        <span style="font-size:13px;color:<?= e($kTier['color']) ?>"><?= $kTier['icon'] ?> <?= e($kTier['name']) ?> — <?= number_format((int)$user['karma']) ?> pts</span>
+      </div>
+      <div class="acard-body">
+        <!-- Progress bar -->
+        <div style="margin-bottom:14px">
+          <div style="display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:4px">
+            <span><?= e($kTier['name']) ?> (<?= $kTier['min'] ?>)</span>
+            <?php if ($kTier['next']): ?>
+              <span><?= e(karma_tier($kTier['next'])['name']) ?> (<?= $kTier['next'] ?>)</span>
+            <?php else: ?>
+              <span>Max tier 🏆</span>
+            <?php endif; ?>
+          </div>
+          <div style="height:8px;background:var(--border);border-radius:4px;overflow:hidden">
+            <div style="height:100%;width:<?= $kTier['progress'] ?>%;background:<?= e($kTier['color']) ?>;border-radius:4px;transition:width .4s"></div>
+          </div>
+          <div style="font-size:11px;color:var(--faint);margin-top:3px;text-align:right"><?= $kTier['progress'] ?>% to next tier</div>
+        </div>
+
+        <form method="POST">
+          <?= csrf_input() ?>
+          <input type="hidden" name="action" value="adjust_karma">
+          <div style="display:grid;grid-template-columns:120px 1fr;gap:10px;margin-bottom:10px">
+            <div class="fg" style="margin:0">
+              <label style="font-size:12px">Operation</label>
+              <select name="karma_op" class="fi" style="padding:7px 10px">
+                <option value="add">➕ Add points</option>
+                <option value="subtract">➖ Subtract points</option>
+                <option value="set">🎯 Set exact value</option>
+              </select>
+            </div>
+            <div class="fg" style="margin:0">
+              <label style="font-size:12px">Amount</label>
+              <input type="number" name="karma_amount" class="fi" value="10" min="0" max="999999"
+                     style="font-family:var(--mono);font-size:16px;text-align:center">
+            </div>
+          </div>
+          <div class="fg" style="margin-bottom:10px">
+            <label style="font-size:12px">Reason <small style="color:var(--faint)">(optional — notifies user)</small></label>
+            <input type="text" name="karma_reason" class="fi" placeholder="e.g. Helpful answer, code of conduct violation…">
+          </div>
+          <button type="submit" class="btn-primary btn-sm">Update Karma</button>
+        </form>
+      </div>
+    </div>
+
+    <div class="acard">
+      <div class="acard-head"><h2>⚡ Quick Actions</h2></div>
+      <div class="acard-body">
+        <div style="display:flex;flex-wrap:wrap;gap:8px">
+          <?php if (!$user['suspended']): ?>
+            <form method="POST" style="display:inline"><?= csrf_input() ?><input type="hidden" name="action" value="suspend">
+              <button class="btn-warn btn-sm" onclick="return confirm('Suspend this user?')">🚫 Suspend</button></form>
+          <?php else: ?>
+            <form method="POST" style="display:inline"><?= csrf_input() ?><input type="hidden" name="action" value="unsuspend">
+              <button class="btn-ok btn-sm">✅ Unsuspend</button></form>
+          <?php endif; ?>
+          <?php if (!$user['silenced']): ?>
+            <form method="POST" style="display:inline"><?= csrf_input() ?><input type="hidden" name="action" value="silence">
+              <button class="btn-warn btn-sm" onclick="return confirm('Silence this user?')">🔇 Silence</button></form>
+          <?php else: ?>
+            <form method="POST" style="display:inline"><?= csrf_input() ?><input type="hidden" name="action" value="unsilence">
+              <button class="btn-ok btn-sm">🔊 Unsilence</button></form>
+          <?php endif; ?>
+          <form method="POST" style="display:inline"><?= csrf_input() ?><input type="hidden" name="action" value="resetpw">
+            <button class="btn-ghost btn-sm" onclick="return confirm('Generate new random password?')">🔑 Reset PW</button></form>
+        </div>
+      </div>
+    </div>
+    <?php else: ?>
+      <div class="acard"><div class="acard-body"><p style="color:#64748b;font-size:13px">You cannot moderate your own account.</p></div></div>
+    <?php endif; ?>
+  </div>
+</div>
+
+<!-- Recent topics & posts -->
+<div class="admin-two-col" style="margin-top:16px">
+  <div class="acard">
+    <div class="acard-head"><h2>Recent Topics</h2></div>
+    <table class="atable">
+      <thead><tr><th>Title</th><th>Category</th><th>Date</th></tr></thead>
+      <tbody>
+        <?php foreach ($utopics as $t): ?>
+          <tr><td><a href="<?= u('forum/topic.php?slug='.urlencode($t['slug'])) ?>" target="_blank"><?= e(mb_substr($t['title'],0,50)) ?></a></td>
+          <td><?= e($t['cat']) ?></td><td><span class="ago" data-ts="<?= e($t['created_at']) ?>"></span></td></tr>
+        <?php endforeach; ?>
+        <?php if (!$utopics): ?><tr><td colspan="3" style="text-align:center;color:#94a3b8;padding:1.5rem">No topics.</td></tr><?php endif; ?>
+      </tbody>
+    </table>
+  </div>
+  <div class="acard">
+    <div class="acard-head"><h2>Recent Posts</h2></div>
+    <table class="atable">
+      <thead><tr><th>In Topic</th><th>Preview</th><th>Date</th></tr></thead>
+      <tbody>
+        <?php foreach ($uposts as $p): ?>
+          <tr><td><a href="<?= u('forum/topic.php?slug='.urlencode($p['ts']).'#post-'.$p['id']) ?>" target="_blank"><?= e(mb_substr($p['tt'],0,30)) ?></a></td>
+          <td><?= e(mb_substr($p['content'],0,60)) ?>…</td><td><span class="ago" data-ts="<?= e($p['created_at']) ?>"></span></td></tr>
+        <?php endforeach; ?>
+        <?php if (!$uposts): ?><tr><td colspan="3" style="text-align:center;color:#94a3b8;padding:1.5rem">No posts.</td></tr><?php endif; ?>
+      </tbody>
+    </table>
+  </div>
+</div>
+
+<script>
+function togglePerms(role) {
+  document.getElementById('permsBox').style.display = (role==='moderator'||role==='admin') ? '' : 'none';
+}
+</script>
+
+<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?>
diff --git a/admin/users.php b/admin/users.php
new file mode 100644
index 0000000..2eb2ceb
--- /dev/null
+++ b/admin/users.php
@@ -0,0 +1,57 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_admin();
+$PAGE_TITLE = 'Users';
+$ADMIN_PAGE = 'users';
+
+$q    = get('q');
+$page = max(1,(int)get('page','1'));
+$pp   = 30;
+$off  = ($page-1)*$pp;
+$like = '%'.$q.'%';
+$where = $q ? "WHERE username LIKE ? OR email LIKE ?" : "";
+$params = $q ? [$like,$like] : [];
+$users = DB::rows("SELECT * FROM users $where ORDER BY joined_at DESC LIMIT $pp OFFSET $off", $params);
+$total = (int)DB::val("SELECT COUNT(*) FROM users $where", $params);
+$pages = max(1,(int)ceil($total/$pp));
+
+include __DIR__ . '/../views/partials/admin_layout.php';
+?>
+<div class="admin-toolbar">
+  <form method="GET" class="at-form">
+    <input type="text" name="q" value="<?= e($q) ?>" placeholder="Search users…" class="fi at-fi">
+    <button type="submit" class="btn-primary">Search</button>
+    <?php if ($q): ?><a href="<?= u('admin/users.php') ?>" class="btn-ghost">Clear</a><?php endif; ?>
+  </form>
+</div>
+<div class="acard">
+  <table class="atable">
+    <thead><tr><th>User</th><th>Email</th><th>Role</th><th>Posts</th><th>Status</th><th>Joined</th><th></th></tr></thead>
+    <tbody>
+      <?php foreach ($users as $u): ?>
+        <tr>
+          <td>@<?= e($u['username']) ?></td>
+          <td><?= e($u['email']) ?></td>
+          <td><span class="role-tag role-<?= e($u['role']) ?>"><?= e($u['role']) ?></span></td>
+          <td><?= $u['post_count'] ?></td>
+          <td>
+            <?php if ($u['suspended']): ?><span class="stag suspended">Suspended</span>
+            <?php elseif ($u['silenced']): ?><span class="stag silenced">Silenced</span>
+            <?php else: ?><span class="stag active">Active</span><?php endif; ?>
+          </td>
+          <td><span class="ago" data-ts="<?= e($u['joined_at']) ?>"></span></td>
+          <td><a href="<?= u('admin/user.php?id='.$u['id']) ?>" class="btn-ghost btn-sm">Manage</a></td>
+        </tr>
+      <?php endforeach; ?>
+      <?php if (!$users): ?><tr><td colspan="7" style="text-align:center;padding:2rem;color:#6b7280">No users found.</td></tr><?php endif; ?>
+    </tbody>
+  </table>
+</div>
+<?php if ($pages>1): ?>
+  <nav class="pager">
+    <?php for($i=1;$i<=$pages;$i++): ?>
+      <a href="?page=<?= $i ?>&q=<?= urlencode($q) ?>" class="pg-btn <?= $i===$page?'active':'' ?>"><?= $i ?></a>
+    <?php endfor; ?>
+  </nav>
+<?php endif; ?>
+<?php include __DIR__ . '/../views/partials/admin_layout_end.php'; ?>
diff --git a/api/chat.php b/api/chat.php
new file mode 100644
index 0000000..e438efe
--- /dev/null
+++ b/api/chat.php
@@ -0,0 +1,155 @@
+<?php
+/**
+ * Live chat API for private messages.
+ * Actions: send, poll, delete, conversations
+ */
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_login();
+if (!csrf_ok()) json_out(['error' => 'CSRF'], 403);
+
+$action = post('action');
+$uid    = (int)$USER['id'];
+
+/* ── Helper: build conversation_id ───────────────────── */
+function conv_id(int $a, int $b): string {
+    return min($a,$b) . '-' . max($a,$b);
+}
+
+/* ── Send a message ──────────────────────────────────── */
+if ($action === 'send') {
+    $toId   = (int)post('to_id');
+    $body   = sanitise(post('body'));
+
+    if (!$toId || $toId === $uid) json_out(['error' => 'Invalid recipient'], 400);
+    if (!$body)                   json_out(['error' => 'Message is empty'], 400);
+    if (mb_strlen($body) > 2000)  json_out(['error' => 'Message too long (max 2000)'], 400);
+
+    $other = DB::row('SELECT id,username FROM users WHERE id=? AND suspended=0', [$toId]);
+    if (!$other) json_out(['error' => 'User not found'], 404);
+
+    $convId = conv_id($uid, $toId);
+
+    $msgId = DB::insert(
+        'INSERT INTO messages (sender_id,receiver_id,subject,body,conversation_id) VALUES (?,?,?,?,?)',
+        [$uid, $toId, '', $body, $convId]
+    );
+
+    // Notify recipient
+    add_notification($toId, 'message', [
+        'from'    => $USER['username'],
+        'from_id' => $uid,
+        'subject' => mb_substr($body, 0, 60) . (mb_strlen($body) > 60 ? '…' : ''),
+    ]);
+
+    // Return the new message row for live append
+    $msg = DB::row(
+        'SELECT m.*, u.username AS sender_name, u.avatar AS sender_av
+         FROM messages m JOIN users u ON u.id=m.sender_id
+         WHERE m.id=?', [$msgId]
+    );
+    json_out(['ok' => true, 'message' => $msg]);
+}
+
+/* ── Poll: fetch new messages since a given id ───────── */
+if ($action === 'poll') {
+    $otherId  = (int)post('other_id');
+    $sinceId  = (int)post('since_id');
+    if (!$otherId) json_out(['error' => 'Missing other_id'], 400);
+
+    $convId = conv_id($uid, $otherId);
+    $msgs   = DB::rows(
+        'SELECT m.*, u.username AS sender_name, u.avatar AS sender_av
+         FROM messages m JOIN users u ON u.id=m.sender_id
+         WHERE m.conversation_id=? AND m.id>?
+         ORDER BY m.id ASC LIMIT 50',
+        [$convId, $sinceId]
+    );
+
+    // Mark messages from other user as read
+    if ($msgs) {
+        $now = DB::now();
+        DB::run(
+            "UPDATE messages SET is_read=1
+             WHERE conversation_id=? AND receiver_id=? AND `is_read`=0",
+            [$convId, $uid]
+        );
+    }
+
+    json_out(['ok' => true, 'messages' => $msgs]);
+}
+
+/* ── Load full conversation ──────────────────────────── */
+if ($action === 'load') {
+    $otherId = (int)post('other_id');
+    $before  = (int)post('before_id'); // for pagination
+    if (!$otherId) json_out(['error' => 'Missing other_id'], 400);
+
+    $convId = conv_id($uid, $otherId);
+    $params = [$convId];
+    $where  = '';
+    if ($before) {
+        $where   = ' AND m.id < ?';
+        $params[] = $before;
+    }
+
+    $msgs = DB::rows(
+        "SELECT m.*, u.username AS sender_name, u.avatar AS sender_av
+         FROM messages m JOIN users u ON u.id=m.sender_id
+         WHERE m.conversation_id=? $where
+         ORDER BY m.id DESC LIMIT 40",
+        $params
+    );
+    $msgs = array_reverse($msgs); // oldest first
+
+    // Mark as read
+    DB::run(
+        "UPDATE messages SET `is_read`=1
+         WHERE conversation_id=? AND receiver_id=? AND `is_read`=0",
+        [$convId, $uid]
+    );
+
+    $other = DB::row('SELECT id,username,avatar,role,karma,last_seen FROM users WHERE id=?', [$otherId]);
+    json_out(['ok' => true, 'messages' => $msgs, 'other' => $other]);
+}
+
+/* ── List conversations ──────────────────────────────── */
+if ($action === 'conversations') {
+    $convs = DB::rows(
+        "SELECT m.*,
+                u.username AS other_name, u.avatar AS other_av,
+                (SELECT COUNT(*) FROM messages m2
+                 WHERE m2.conversation_id=m.conversation_id
+                   AND m2.receiver_id=? AND m2.is_read=0) AS unread_count
+         FROM messages m
+         JOIN users u ON u.id = CASE WHEN m.sender_id=? THEN m.receiver_id ELSE m.sender_id END
+         WHERE m.conversation_id IN (
+             SELECT conversation_id FROM messages
+             WHERE sender_id=? OR receiver_id=?
+         )
+         AND m.id IN (
+             SELECT MAX(id) FROM messages
+             WHERE sender_id=? OR receiver_id=?
+             GROUP BY conversation_id
+         )
+         ORDER BY m.created_at DESC",
+        [$uid, $uid, $uid, $uid, $uid, $uid]
+    );
+    json_out(['ok' => true, 'conversations' => $convs]);
+}
+
+/* ── Delete a message ────────────────────────────────── */
+if ($action === 'delete') {
+    $msgId = (int)post('msg_id');
+    $msg   = DB::row('SELECT * FROM messages WHERE id=?', [$msgId]);
+    if (!$msg) json_out(['error' => 'Not found'], 404);
+    if ($msg['sender_id'] !== $uid && $msg['receiver_id'] !== $uid) json_out(['error' => 'Forbidden'], 403);
+
+    if ($msg['sender_id'] === $uid)
+        DB::run('UPDATE messages SET deleted_by_sender=1 WHERE id=?', [$msgId]);
+    else
+        DB::run('UPDATE messages SET deleted_by_receiver=1 WHERE id=?', [$msgId]);
+
+    json_out(['ok' => true]);
+}
+
+json_out(['error' => 'Unknown action'], 400);
diff --git a/api/delete.php b/api/delete.php
new file mode 100644
index 0000000..1f3646b
--- /dev/null
+++ b/api/delete.php
@@ -0,0 +1,10 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+if (!$USER) json_out(['error'=>'Not logged in'],401);
+if (!csrf_ok()) json_out(['error'=>'CSRF'],403);
+$pid  = (int)post('post_id');
+$post = DB::row('SELECT * FROM posts WHERE id=?',[$pid]);
+if (!$post) json_out(['error'=>'Not found'],404);
+if ($post['user_id']!==$USER['id'] && !is_admin()) json_out(['error'=>'Forbidden'],403);
+DB::run('UPDATE posts SET deleted=1 WHERE id=?',[$pid]);
+json_out(['ok'=>true]);
diff --git a/api/edit.php b/api/edit.php
new file mode 100644
index 0000000..40bebfd
--- /dev/null
+++ b/api/edit.php
@@ -0,0 +1,21 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+if (!$USER) json_out(['error'=>'Not logged in'],401);
+if (!csrf_ok()) json_out(['error'=>'CSRF'],403);
+
+$pid     = (int)post('post_id');
+$content = sanitise(post('content'));
+$reason  = sanitise(post('reason'));
+
+if (!$pid || !$content) json_out(['error'=>'Missing fields'],400);
+if (strlen($content) > 20000) json_out(['error'=>'Post too long'],400);
+
+$post = DB::row('SELECT * FROM posts WHERE id=?',[$pid]);
+if (!$post) json_out(['error'=>'Not found'],404);
+if ($post['user_id']!==$USER['id'] && !is_admin()) json_out(['error'=>'Forbidden'],403);
+
+$now = DB::now();
+DB::run("UPDATE posts SET content=?,edited=1,edit_reason=?,updated_at=$now WHERE id=?",
+    [$content, $reason ?: null, $pid]);
+
+json_out(['ok'=>true,'content'=>$content]);
diff --git a/api/friend.php b/api/friend.php
new file mode 100644
index 0000000..493894e
--- /dev/null
+++ b/api/friend.php
@@ -0,0 +1,60 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+if (!$USER) json_out(['error'=>'Not logged in'],401);
+if (!csrf_ok()) json_out(['error'=>'CSRF'],403);
+
+$action   = sanitise(post('action'));
+$targetId = (int)post('target_id');
+if (!$targetId || $targetId === $USER['id']) json_out(['error'=>'Invalid target'],400);
+
+$target = DB::row('SELECT id,username FROM users WHERE id=?',[$targetId]);
+if (!$target) json_out(['error'=>'User not found'],404);
+
+$uid = (int)$USER['id'];
+
+switch ($action) {
+    case 'send':
+        // Send friend request
+        $existing = DB::row('SELECT * FROM friends WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)',
+            [$uid,$targetId,$targetId,$uid]);
+        if ($existing) json_out(['error'=>'Request already exists'],400);
+        DB::insert('INSERT INTO friends (user_id,friend_id,status) VALUES (?,?,\'pending\')',[$uid,$targetId]);
+        add_notification($targetId,'friend_request',[
+            'from'=>$USER['username'],'from_id'=>$uid
+        ]);
+        json_out(['ok'=>true,'msg'=>'Friend request sent!']);
+        break;
+
+    case 'cancel':
+        // Cancel outgoing request
+        DB::run('DELETE FROM friends WHERE user_id=? AND friend_id=? AND status=\'pending\'',[$uid,$targetId]);
+        json_out(['ok'=>true,'msg'=>'Request cancelled.']);
+        break;
+
+    case 'accept':
+        // Accept incoming request
+        $req = DB::row('SELECT * FROM friends WHERE user_id=? AND friend_id=? AND status=\'pending\'',[$targetId,$uid]);
+        if (!$req) json_out(['error'=>'No pending request found'],404);
+        DB::run("UPDATE friends SET status='accepted' WHERE id=?",[$req['id']]);
+        add_notification($targetId,'friend_accepted',[
+            'from'=>$USER['username'],'from_id'=>$uid
+        ]);
+        json_out(['ok'=>true,'msg'=>'Friend request accepted!']);
+        break;
+
+    case 'decline':
+        // Decline incoming request
+        DB::run('DELETE FROM friends WHERE user_id=? AND friend_id=? AND status=\'pending\'',[$targetId,$uid]);
+        json_out(['ok'=>true,'msg'=>'Request declined.']);
+        break;
+
+    case 'remove':
+        // Remove existing friendship
+        DB::run('DELETE FROM friends WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)',
+            [$uid,$targetId,$targetId,$uid]);
+        json_out(['ok'=>true,'msg'=>'Friend removed.']);
+        break;
+
+    default:
+        json_out(['error'=>'Unknown action'],400);
+}
diff --git a/api/karma.php b/api/karma.php
new file mode 100644
index 0000000..79c540c
--- /dev/null
+++ b/api/karma.php
@@ -0,0 +1,46 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_admin();
+if (!csrf_ok()) json_out(['error' => 'CSRF'], 403);
+
+$uid    = (int)post('user_id');
+$action = post('action'); // set | add | subtract
+$amount = (int)post('amount');
+$reason = sanitise(post('reason'));
+
+if (!$uid)                              json_out(['error' => 'Invalid user'],    400);
+if (!in_array($action, ['set','add','subtract'])) json_out(['error' => 'Invalid action'], 400);
+if ($amount < 0)                        json_out(['error' => 'Amount must be >= 0'], 400);
+if ($amount > 999999)                   json_out(['error' => 'Amount too large'], 400);
+
+$user = DB::row('SELECT id, username, karma FROM users WHERE id=?', [$uid]);
+if (!$user) json_out(['error' => 'User not found'], 404);
+
+$old = (int)$user['karma'];
+
+switch ($action) {
+    case 'set':
+        $new = $amount;
+        DB::run('UPDATE users SET karma=? WHERE id=?', [$new, $uid]);
+        break;
+    case 'add':
+        DB::run('UPDATE users SET karma=karma+? WHERE id=?', [$amount, $uid]);
+        $new = $old + $amount;
+        break;
+    case 'subtract':
+        $new = max(0, $old - $amount);
+        DB::run('UPDATE users SET karma=? WHERE id=?', [$new, $uid]);
+        break;
+}
+
+// Add an audit notification to the user
+if ($reason) {
+    add_notification($uid, 'karma_admin', [
+        'from'   => $USER['username'],
+        'change' => ($new - $old),
+        'new'    => $new,
+        'reason' => $reason,
+    ]);
+}
+
+json_out(['ok' => true, 'old' => $old, 'new' => $new, 'username' => $user['username']]);
diff --git a/api/like.php b/api/like.php
new file mode 100644
index 0000000..f3e0a78
--- /dev/null
+++ b/api/like.php
@@ -0,0 +1,25 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+if (!$USER) json_out(['error'=>'Not logged in'],401);
+if (!csrf_ok()) json_out(['error'=>'CSRF'],403);
+$pid = (int)post('post_id');
+if (!$pid) json_out(['error'=>'Missing post_id'],400);
+$post = DB::row('SELECT * FROM posts WHERE id=?',[$pid]);
+if (!$post) json_out(['error'=>'Not found'],404);
+
+$exists = DB::row('SELECT 1 FROM likes WHERE post_id=? AND user_id=?',[$pid,$USER['id']]);
+if ($exists) {
+    DB::run('DELETE FROM likes WHERE post_id=? AND user_id=?',[$pid,$USER['id']]);
+    DB::run('UPDATE posts SET likes=MAX(0,likes-1) WHERE id=?',[$pid]);
+    // Remove karma from post author
+    add_karma((int)$post['user_id'], -1);
+    $liked = false;
+} else {
+    DB::insertIgnore('likes', ['post_id','user_id'], [$pid,$USER['id']]);
+    DB::run('UPDATE posts SET likes=likes+1 WHERE id=?',[$pid]);
+    // Award karma to post author
+    add_karma((int)$post['user_id'], 1);
+    $liked = true;
+}
+$count = (int)DB::val('SELECT COUNT(*) FROM likes WHERE post_id=?',[$pid]);
+json_out(['ok'=>true,'liked'=>$liked,'count'=>$count]);
diff --git a/api/notifications.php b/api/notifications.php
new file mode 100644
index 0000000..aa6879a
--- /dev/null
+++ b/api/notifications.php
@@ -0,0 +1,20 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+if (!$USER) json_out([],401);
+
+// Mark-read requires POST + valid CSRF
+if ($_SERVER['REQUEST_METHOD']==='POST' && post('action')==='read') {
+    if (!csrf_ok()) json_out(['error'=>'Invalid CSRF'],403);
+    DB::run('UPDATE notifications SET `read`=1 WHERE user_id=?',[$USER['id']]);
+    json_out(['ok'=>true]);
+}
+// Allow GET for polling
+if (isset($_GET['action']) && $_GET['action']==='read') {
+    // Legacy GET — accept but deprecated
+    DB::run('UPDATE notifications SET `read`=1 WHERE user_id=?',[$USER['id']]);
+    json_out(['ok'=>true]);
+}
+
+$rows = DB::rows('SELECT * FROM notifications WHERE user_id=? ORDER BY created_at DESC LIMIT 25',[$USER['id']]);
+foreach ($rows as &$r) $r['payload'] = json_decode($r['payload'],true) ?: [];
+json_out($rows);
diff --git a/api/reply.php b/api/reply.php
new file mode 100644
index 0000000..8fe0fba
--- /dev/null
+++ b/api/reply.php
@@ -0,0 +1,69 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+if (!$USER) json_out(['error'=>'Not logged in'],401);
+if ($_SERVER['REQUEST_METHOD']!=='POST') json_out(['error'=>'Method not allowed'],405);
+if (!csrf_ok()) json_out(['error'=>'Invalid CSRF token'],403);
+if ($USER['silenced']) json_out(['error'=>'You are silenced and cannot post'],403);
+
+// ── Rate limit check ─────────────────────────────────────────
+$rl = rate_check((int)$USER['id'], 'post');
+if (!$rl['ok']) {
+    $wait = (int)$rl['wait'];
+    json_out(['error'=>"You're posting too fast. Please wait {$wait} second".($wait!==1?'s':'').' before posting again.','rate_limited'=>true,'wait'=>$wait], 429);
+}
+
+// ── Post captcha check ───────────────────────────────────────
+if (post_captcha_enabled()) {
+    $cap = post('post_captcha');
+    if (!post_captcha_verify($cap)) {
+        json_out(['error'=>'Incorrect security answer.','captcha_failed'=>true], 403);
+    }
+}
+
+$slug    = sanitise(post('slug'));
+$content = sanitise(post('content'));
+$replyTo = (int)post('reply_to');
+
+if (!$slug || !$content) json_out(['error'=>'Missing required fields'],400);
+if (strlen($content) < 1)     json_out(['error'=>'Reply cannot be empty'],400);
+if (strlen($content) > 20000) json_out(['error'=>'Post too long (max 20,000 chars)'],400);
+
+$topic = DB::row('SELECT * FROM topics WHERE slug=?', [$slug]);
+if (!$topic) json_out(['error'=>'Topic not found'],404);
+if ($topic['closed']) json_out(['error'=>'This topic is closed'],403);
+$replyCat = DB::row('SELECT * FROM categories WHERE id=?',[$topic['category_id']]);
+if (!can_reply_topic($replyCat ?? [])) json_out(['error'=>'No permission to reply in this category'],403);
+
+$last = DB::row('SELECT post_num FROM posts WHERE topic_id=? ORDER BY post_num DESC LIMIT 1',[$topic['id']]);
+$num  = ($last ? (int)$last['post_num'] : 0) + 1;
+
+$pid = DB::insert(
+    'INSERT INTO posts (topic_id,user_id,content,post_num,reply_to) VALUES (?,?,?,?,?)',
+    [$topic['id'], $USER['id'], $content, $num, $replyTo ?: null]
+);
+
+// Record rate limit event AFTER successful post
+rate_record((int)$USER['id'], 'post');
+
+$now = DB::now();
+DB::run("UPDATE topics SET last_post_at=$now,reply_count=reply_count+1 WHERE id=?",[$topic['id']]);
+DB::run('UPDATE users SET post_count=post_count+1 WHERE id=?',[$USER['id']]);
+DB::run('UPDATE categories SET post_count=post_count+1 WHERE id=?',[$topic['category_id']]);
+
+if ($topic['user_id'] !== $USER['id']) {
+    add_notification((int)$topic['user_id'],'reply',[
+        'slug'=>$topic['slug'],'title'=>$topic['title'],'from'=>$USER['username']
+    ]);
+}
+process_mentions($content, $pid, $topic['slug'], (int)$USER['id'], $USER['username']);
+
+$post = DB::row("
+    SELECT p.*,u.username,u.avatar,u.role,u.post_count,0 AS likes
+    FROM posts p JOIN users u ON u.id=p.user_id WHERE p.id=?
+",[$pid]);
+
+// Send new captcha if post captcha enabled
+$newCaptcha = post_captcha_enabled() ? post_captcha_generate() : null;
+
+addon_hook('after_reply_saved', ['post_id'=>$pid,'topic_id'=>$topic['id'],'user_id'=>(int)$USER['id']]);
+json_out(['ok'=>true,'post'=>$post,'new_captcha'=>$newCaptcha]);
diff --git a/api/search.php b/api/search.php
new file mode 100644
index 0000000..4eeb1c2
--- /dev/null
+++ b/api/search.php
@@ -0,0 +1,34 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+
+$q = get('q');
+if (mb_strlen($q) < 2) json_out(['topics' => [], 'posts' => []]);
+
+$like = '%' . $q . '%';
+
+// Topic title matches
+$topics = DB::rows(
+    "SELECT t.id, t.title, t.slug, c.name AS cat, c.color AS cat_color
+     FROM topics t
+     JOIN categories c ON c.id = t.category_id
+     WHERE t.title LIKE ?
+     ORDER BY t.last_post_at DESC
+     LIMIT 6",
+    [$like]
+);
+
+// Post content matches — include post_id for goto link
+$posts = DB::rows(
+    "SELECT p.id AS post_id, p.content, p.post_num,
+            t.title AS topic_title, t.slug AS topic_slug,
+            u.username
+     FROM posts p
+     JOIN topics t ON t.id = p.topic_id
+     JOIN users u  ON u.id = p.user_id
+     WHERE p.content LIKE ? AND p.deleted = 0
+     ORDER BY p.created_at DESC
+     LIMIT 5",
+    [$like]
+);
+
+json_out(['topics' => $topics, 'posts' => $posts]);
diff --git a/api/search_users.php b/api/search_users.php
new file mode 100644
index 0000000..6deecbf
--- /dev/null
+++ b/api/search_users.php
@@ -0,0 +1,9 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+$q = sanitise(get('q'));
+if (mb_strlen($q) < 2) json_out([]);
+$rows = DB::rows(
+    "SELECT id, username, avatar FROM users WHERE username LIKE ? AND suspended=0 ORDER BY username LIMIT 8",
+    [$q.'%']
+);
+json_out($rows);
diff --git a/api/topic_action.php b/api/topic_action.php
new file mode 100644
index 0000000..a61f544
--- /dev/null
+++ b/api/topic_action.php
@@ -0,0 +1,21 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_admin();
+if (!csrf_ok()) go('/');
+$id     = (int)post('id');
+$action = post('action');
+// Validate referer to prevent open redirect
+$rawRef  = $_SERVER['HTTP_REFERER'] ?? '';
+$baseUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
+$ref     = (strpos($rawRef, $baseUrl) === 0) ? $rawRef : u('/');
+$map    = [
+    'pin'       => 'UPDATE topics SET pinned=1 WHERE id=?',
+    'unpin'     => 'UPDATE topics SET pinned=0 WHERE id=?',
+    'close'     => 'UPDATE topics SET closed=1 WHERE id=?',
+    'open'      => 'UPDATE topics SET closed=0 WHERE id=?',
+    'archive'   => 'UPDATE topics SET archived=1 WHERE id=?',
+    'unarchive' => 'UPDATE topics SET archived=0 WHERE id=?',
+];
+if ($id && isset($map[$action])) DB::run($map[$action], [$id]);
+header('Location: ' . $ref);
+exit;
diff --git a/api/upload.php b/api/upload.php
new file mode 100644
index 0000000..5058b1e
--- /dev/null
+++ b/api/upload.php
@@ -0,0 +1,105 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+
+// ── Auth & method ────────────────────────────────────────────────
+if (!$USER)
+    json_out(['error' => 'Not logged in'], 401);
+if ($_SERVER['REQUEST_METHOD'] !== 'POST')
+    json_out(['error' => 'Method not allowed'], 405);
+if (!csrf_ok())
+    json_out(['error' => 'Invalid CSRF token'], 403);
+
+// ── File present? ────────────────────────────────────────────────
+// $_FILES is empty when PHP silently drops the upload because it exceeds
+// php.ini upload_max_filesize or post_max_size.
+if (empty($_FILES['file']) || !isset($_FILES['file']['tmp_name'])) {
+    $phpLimit = ini_get('upload_max_filesize') ?: '?';
+    json_out(['error' => 'No file received. Check PHP upload_max_filesize (currently: ' . $phpLimit . ')'], 400);
+}
+
+$f    = $_FILES['file'];
+$code = $f['error'] ?? UPLOAD_ERR_OK;
+
+// ── PHP upload error codes ────────────────────────────────────────
+if ($code !== UPLOAD_ERR_OK) {
+    $msgs = [
+        UPLOAD_ERR_INI_SIZE   => 'File exceeds server upload limit (upload_max_filesize)',
+        UPLOAD_ERR_FORM_SIZE  => 'File exceeds form size limit',
+        UPLOAD_ERR_PARTIAL    => 'File was only partially uploaded',
+        UPLOAD_ERR_NO_FILE    => 'No file was uploaded',
+        UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
+        UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
+        UPLOAD_ERR_EXTENSION  => 'Upload blocked by a PHP extension',
+    ];
+    json_out(['error' => $msgs[$code] ?? 'Upload error code ' . $code], 400);
+}
+
+// ── Detect real MIME type from file content (not browser header) ──
+// finfo is reliable; fall back to getimagesize if finfo not available.
+$realMime = null;
+if (function_exists('finfo_open')) {
+    $fi       = finfo_open(FILEINFO_MIME_TYPE);
+    $realMime = finfo_file($fi, $f['tmp_name']);
+    finfo_close($fi);
+} elseif (function_exists('mime_content_type')) {
+    $realMime = mime_content_type($f['tmp_name']);
+}
+
+// Validate the real MIME type
+$allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
+if ($realMime && !in_array($realMime, $allowed)) {
+    json_out(['error' => 'Only JPEG, PNG, GIF and WebP images are allowed (detected: ' . $realMime . ')'], 400);
+}
+
+// ── Size limit ────────────────────────────────────────────────────
+$maxMb    = max(1, min(50, (int) cfg('max_upload_mb', '5')));
+$maxBytes = $maxMb * 1024 * 1024;
+if ($f['size'] > $maxBytes) {
+    json_out(['error' => 'Image too large — maximum is ' . $maxMb . ' MB'], 400);
+}
+
+// ── Validate it is a real image (catches non-images finfo might miss) ──
+$info = @getimagesize($f['tmp_name']);
+if (!$info) {
+    json_out(['error' => 'File does not appear to be a valid image'], 400);
+}
+
+// ── Safe extension from detected MIME ────────────────────────────
+$mimeToExt = [
+    'image/jpeg' => 'jpg',
+    'image/png'  => 'png',
+    'image/gif'  => 'gif',
+    'image/webp' => 'webp',
+];
+$detectedMime = $realMime ?: $info['mime'];
+$ext  = $mimeToExt[$detectedMime] ?? ($mimeToExt[$info['mime']] ?? 'jpg');
+$name = 'img_' . uniqid('', true) . '.' . $ext;
+
+// ── Ensure upload directory exists and is writable ────────────────
+$dir = UPLOADS . '/';
+if (!is_dir($dir)) {
+    if (!mkdir($dir, 0755, true)) {
+        json_out(['error' => 'Upload directory could not be created'], 500);
+    }
+}
+if (!is_writable($dir)) {
+    json_out(['error' => 'Upload directory is not writable'], 500);
+}
+
+// ── Move file ─────────────────────────────────────────────────────
+if (!move_uploaded_file($f['tmp_name'], $dir . $name)) {
+    json_out(['error' => 'Could not save uploaded file'], 500);
+}
+
+// ── Add security .htaccess to uploads dir (prevent PHP execution) ─
+$ht = $dir . '.htaccess';
+if (!file_exists($ht)) {
+    file_put_contents($ht,
+        "# Deny PHP execution in uploads\n" .
+        "<FilesMatch \"\\.php\$\">\n" .
+        "    Require all denied\n" .
+        "</FilesMatch>\n"
+    );
+}
+
+json_out(['ok' => true, 'url' => BASE . '/public/uploads/' . $name]);
diff --git a/auth/login.php b/auth/login.php
new file mode 100644
index 0000000..b083ab8
--- /dev/null
+++ b/auth/login.php
@@ -0,0 +1,65 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+if ($USER) go('/');
+
+$err = '';
+$next = get('next', u('/'));
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    if (!csrf_ok()) { $err = 'Invalid request. Please try again.'; }
+    else {
+        $login = post('login');
+        $pass  = post('password');
+        $row   = DB::row('SELECT * FROM users WHERE username=? OR email=?', [$login, $login]);
+        if (!$row || !password_verify($pass, $row['password'])) {
+            $err = 'Incorrect username or password.';
+        } elseif ($row['suspended']) {
+            $err = 'This account has been suspended.';
+        } else {
+            login_user((int)$row['id']);
+            go(ltrim(str_replace(BASE, '', $next), '/') ?: '/');
+        }
+    }
+}
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+  <title>Log In — <?= e(cfg('site_name','Nexus Forum')) ?></title>
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
+  <link rel="stylesheet" href="<?= asset('css/main.css') ?>">
+</head>
+<body class="auth-body">
+<div class="auth-page">
+  <div class="auth-card">
+    <a href="<?= u('/') ?>" class="auth-logo">
+      <div class="logo-mark"><?= e(substr(cfg('site_name','N'),0,1)) ?></div>
+      <span><?= e(cfg('site_name','Nexus Forum')) ?></span>
+    </a>
+    <h1>Welcome back</h1>
+    <p class="auth-sub">Sign in to your account</p>
+    <?php if ($err): ?><div class="alert err"><?= e($err) ?></div><?php endif; ?>
+    <form method="POST">
+      <?= csrf_input() ?>
+      <input type="hidden" name="next" value="<?= e($next) ?>">
+      <div class="fg">
+        <label for="login">Username or Email</label>
+        <input type="text" id="login" name="login" class="fi" required autofocus
+               value="<?= e($_POST['login'] ?? '') ?>" placeholder="your_username">
+      </div>
+      <div class="fg">
+        <label for="password">Password</label>
+        <div class="pw-row">
+          <input type="password" id="password" name="password" class="fi" required placeholder="••••••••">
+          <button type="button" class="pw-eye" onclick="togglePwd('password')">👁</button>
+        </div>
+      </div>
+      <button type="submit" class="btn-primary btn-block">Sign In</button>
+    </form>
+    <p class="auth-foot">No account? <a href="<?= u('auth/register.php') ?>">Sign up →</a></p>
+  </div>
+  <p class="auth-back"><a href="<?= u('/') ?>">← Back to forum</a></p>
+</div>
+<script>function togglePwd(id){var e=document.getElementById(id);e.type=e.type==='password'?'text':'password';}</script>
+</body></html>
diff --git a/auth/logout.php b/auth/logout.php
new file mode 100644
index 0000000..7a1cad8
--- /dev/null
+++ b/auth/logout.php
@@ -0,0 +1,4 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+logout_user();
+go('/');
diff --git a/auth/register.php b/auth/register.php
new file mode 100644
index 0000000..1dff045
--- /dev/null
+++ b/auth/register.php
@@ -0,0 +1,168 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+if ($USER) go('/');
+if (cfg('allow_reg','1') !== '1') $disabled = true;
+
+$errs = [];
+
+// IMPORTANT: Only generate a NEW captcha on GET requests (page load).
+// On POST requests, verify FIRST against the stored session answer,
+// then generate a new one only if needed for re-display.
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    $captcha = captcha_generate();
+} else {
+    // On POST: we don't overwrite the session answer yet.
+    // captcha_verify() will read and unset it.
+    $captcha = ['q' => ''];  // placeholder, overwritten below if needed
+}
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && !isset($disabled)) {
+    if (!csrf_ok()) {
+        $errs[]  = 'Invalid request. Please refresh and try again.';
+        $captcha = captcha_generate();
+    } else {
+        $name  = sanitise(post('username'));
+        $email = sanitise(post('email'));
+        $pass  = post('password');
+        $pass2 = post('password2');
+        $cap   = post('captcha');
+
+        // Verify captcha FIRST before anything else
+        if (!captcha_verify($cap)) {
+            $errs[]  = 'Incorrect answer to the security question. Please try again.';
+            $captcha = captcha_generate(); // generate fresh question for retry
+        }
+
+        // Only run other validation if captcha passed
+        if (!$errs) {
+            if (strlen($name) < 3 || strlen($name) > 30)          $errs[] = 'Username must be 3–30 characters.';
+            if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $name))         $errs[] = 'Username: letters, numbers, _ and - only.';
+            if (!filter_var($email, FILTER_VALIDATE_EMAIL))         $errs[] = 'Invalid email address.';
+            if (strlen($pass) < 8)                                  $errs[] = 'Password must be at least 8 characters.';
+            if ($pass !== $pass2)                                   $errs[] = 'Passwords do not match.';
+        }
+
+        if (!$errs) {
+            if (DB::row('SELECT id FROM users WHERE username=? OR email=?', [$name, $email])) {
+                $errs[] = 'Username or email is already taken.';
+                $captcha = captcha_generate(); // fresh question after failed attempt
+            } else {
+                $id = DB::insert(
+                    'INSERT INTO users (username, email, password) VALUES (?, ?, ?)',
+                    [$name, $email, password_hash($pass, PASSWORD_BCRYPT, ['cost' => 12])]
+                );
+                addon_hook('after_user_registered', ['user_id'=>$id,'username'=>$name,'email'=>$email]);
+                login_user($id);
+                go('/');
+            }
+        }
+
+        // If we have errors but captcha already passed (errs from other fields),
+        // generate a fresh captcha for the re-shown form
+        if ($errs && empty(array_filter($errs, fn($e) => str_contains($e, 'security')))) {
+            if (!isset($captcha['q']) || $captcha['q'] === '') {
+                $captcha = captcha_generate();
+            }
+        }
+    }
+}
+?><!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+  <title>Sign Up — <?= e(cfg('site_name','Nexus Forum')) ?></title>
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
+  <link rel="stylesheet" href="<?= asset('css/main.css') ?>">
+</head>
+<body class="auth-body">
+<div class="auth-page">
+  <div class="auth-card">
+    <a href="<?= u('/') ?>" class="auth-logo">
+      <div class="logo-mark"><?= e(substr(cfg('site_name','N'),0,1)) ?></div>
+      <span><?= e(cfg('site_name','Nexus Forum')) ?></span>
+    </a>
+    <h1>Create an account</h1>
+    <p class="auth-sub">Join the community</p>
+
+    <?php if (!empty($errs)): ?>
+      <div class="alert err"><?= implode('<br>', array_map('e', $errs)) ?></div>
+    <?php endif; ?>
+
+    <?php if (isset($disabled)): ?>
+      <div class="alert warn">Registration is currently disabled.</div>
+    <?php else: ?>
+    <form method="POST" autocomplete="off">
+      <?= csrf_input() ?>
+      <div class="fg">
+        <label>Username</label>
+        <input type="text" name="username" class="fi" required autofocus
+               value="<?= e($_POST['username'] ?? '') ?>"
+               placeholder="your_username" minlength="3" maxlength="30">
+        <span class="hint">Letters, numbers, _ and - only</span>
+      </div>
+      <div class="fg">
+        <label>Email</label>
+        <input type="email" name="email" class="fi" required
+               value="<?= e($_POST['email'] ?? '') ?>" placeholder="you@example.com">
+      </div>
+      <div class="fg">
+        <label>Password</label>
+        <div class="pw-row">
+          <input type="password" id="pw1" name="password" class="fi" required
+                 placeholder="Min. 8 characters" minlength="8">
+          <button type="button" class="pw-eye" onclick="togglePwd('pw1')">👁</button>
+        </div>
+        <div class="pw-bar"><div class="pw-fill" id="pwFill"></div></div>
+        <span class="hint" id="pwHint"></span>
+      </div>
+      <div class="fg">
+        <label>Confirm Password</label>
+        <div class="pw-row">
+          <input type="password" id="pw2" name="password2" class="fi" required
+                 placeholder="Repeat password">
+          <button type="button" class="pw-eye" onclick="togglePwd('pw2')">👁</button>
+        </div>
+      </div>
+
+      <!-- Math Captcha -->
+      <div class="captcha-box">
+        <div class="captcha-label">
+          🔒 Security check — What is
+          <strong class="captcha-q"><?= e($captcha['q']) ?></strong>
+        </div>
+        <input type="number" name="captcha" class="fi captcha-input"
+               required placeholder="Your answer" autocomplete="off">
+        <span class="hint">Solve this simple math problem to continue</span>
+      </div>
+
+      <button type="submit" class="btn-primary btn-block" style="margin-top:16px">
+        Create Account
+      </button>
+    </form>
+    <?php endif; ?>
+    <p class="auth-foot">Have an account? <a href="<?= u('auth/login.php') ?>">Sign in →</a></p>
+  </div>
+  <p class="auth-back"><a href="<?= u('/') ?>">← Back to forum</a></p>
+</div>
+<script>
+function togglePwd(id) {
+  var e = document.getElementById(id);
+  e.type = e.type === 'password' ? 'text' : 'password';
+}
+var pw = document.getElementById('pw1');
+if (pw) pw.addEventListener('input', function() {
+  var p=this.value, f=document.getElementById('pwFill'), h=document.getElementById('pwHint'), s=0;
+  if (p.length >= 8)  s++;
+  if (p.length >= 12) s++;
+  if (/[A-Z]/.test(p)) s++;
+  if (/[0-9]/.test(p)) s++;
+  if (/[^A-Za-z0-9]/.test(p)) s++;
+  f.style.width = (s / 5 * 100) + '%';
+  var cols = ['','#ef4444','#f59e0b','#f59e0b','#22c55e','#22c55e'];
+  f.style.background = cols[s] || '#22c55e';
+  h.textContent = ['','Weak','Fair','Good','Strong','Very strong'][s] || '';
+  h.style.color = f.style.background;
+});
+</script>
+</body>
+</html>
diff --git a/forum/category.php b/forum/category.php
new file mode 100644
index 0000000..a82c496
--- /dev/null
+++ b/forum/category.php
@@ -0,0 +1,102 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+
+$slug = get('slug');
+$cat  = DB::row('SELECT * FROM categories WHERE slug=?', [$slug]);
+if (!$cat) render_404();
+if (!can_read_category($cat)) render_403();
+
+$page = max(1, (int)get('page','1'));
+$pp   = (int)cfg('topics_per_page','30');
+$off  = ($page - 1) * $pp;
+
+$topics = DB::rows("
+    SELECT t.*, u.username, u.avatar,
+      (SELECT u2.username FROM posts p2 JOIN users u2 ON u2.id=p2.user_id
+       WHERE p2.topic_id=t.id ORDER BY p2.created_at DESC LIMIT 1) AS last_poster,
+      (SELECT COUNT(*)-1 FROM posts p WHERE p.topic_id=t.id AND p.deleted=0) AS replies
+    FROM topics t JOIN users u ON u.id=t.user_id
+    WHERE t.category_id=? AND t.archived=0
+    ORDER BY t.pinned DESC, t.last_post_at DESC
+    LIMIT ? OFFSET ?
+", [$cat['id'], $pp, $off]);
+
+$total = (int) DB::val('SELECT COUNT(*) FROM topics WHERE category_id=? AND archived=0', [$cat['id']]);
+$pages = max(1, (int)ceil($total / $pp));
+$subs  = DB::rows('SELECT * FROM categories WHERE parent_id=? ORDER BY position', [$cat['id']]);
+
+$PAGE_TITLE = $cat['name'];
+include __DIR__ . '/../views/partials/layout.php';
+?>
+<nav class="bc"><a href="<?= u('/') ?>">Home</a> › <span><?= e($cat['name']) ?></span></nav>
+
+<div class="cat-hdr" style="border-left:4px solid <?= e($cat['color']) ?>">
+  <span class="cat-hdr-icon"><?= e($cat['icon']) ?></span>
+  <div>
+    <h1><?= e($cat['name']) ?></h1>
+    <p><?= e($cat['description']) ?></p>
+    <div class="cat-hdr-meta"><?= $total ?> topics · <?= $cat['post_count'] ?> posts</div>
+  </div>
+  <?php if ($USER): ?>
+    <a href="<?= u('forum/new-topic.php?cat=' . $cat['id']) ?>" class="btn-primary">+ New Topic</a>
+  <?php endif; ?>
+</div>
+
+<?php if ($subs): ?>
+  <div class="subcats">
+    <?php foreach ($subs as $s): ?>
+      <a href="<?= u('forum/category.php?slug=' . urlencode($s['slug'])) ?>" class="subcat" style="border-color:<?= e($s['color']) ?>"><?= e($s['icon']) ?> <?= e($s['name']) ?></a>
+    <?php endforeach; ?>
+  </div>
+<?php endif; ?>
+
+<div class="topic-list">
+  <div class="tl-hdr"><span>Topic</span><span class="tl-r">Replies</span><span class="tl-r">Views</span><span class="tl-r">Activity</span></div>
+  <?php foreach ($topics as $t): ?>
+    <div class="tl-row <?= $t['pinned'] ? 'is-pinned' : '' ?>">
+      <div class="tl-main">
+        <?php if ($t['avatar']): ?>
+          <img src="<?= e($t['avatar']) ?>" class="av-sm" alt="">
+        <?php else: ?>
+          <span class="av-sm av-init"><?= strtoupper($t['username'][0]) ?></span>
+        <?php endif; ?>
+        <div>
+          <div class="tl-title">
+            <?php if ($t['pinned']): ?><span title="Pinned">📌</span><?php endif; ?>
+            <?php if ($t['closed']): ?><span title="Closed">🔒</span><?php endif; ?>
+            <a href="<?= u('forum/topic.php?slug=' . urlencode($t['slug'])) ?>"><?= e($t['title']) ?></a>
+          </div>
+          <div class="tl-meta">
+            by <a href="<?= u('users/profile.php?u=' . urlencode($t['username'])) ?>">@<?= e($t['username']) ?></a>
+            <?php if ($t['last_poster'] && $t['last_poster'] !== $t['username']): ?>
+              · last by @<?= e($t['last_poster']) ?>
+            <?php endif; ?>
+          </div>
+        </div>
+      </div>
+      <div class="tl-r"><?= max(0,(int)$t['replies']) ?></div>
+      <div class="tl-r"><?= $t['views'] ?></div>
+      <div class="tl-r"><span class="ago" data-ts="<?= e($t['last_post_at']) ?>"></span></div>
+    </div>
+  <?php endforeach; ?>
+  <?php if (empty($topics)): ?>
+    <div class="empty-state">
+      <p>No topics yet.</p>
+      <?php if ($USER): ?>
+        <a href="<?= u('forum/new-topic.php?cat=' . $cat['id']) ?>" class="btn-primary">Start the first one!</a>
+      <?php endif; ?>
+    </div>
+  <?php endif; ?>
+</div>
+
+<?php if ($pages > 1): ?>
+  <nav class="pager">
+    <?php if ($page > 1): ?><a href="?slug=<?= urlencode($slug) ?>&page=<?= $page-1 ?>" class="pg-btn">← Prev</a><?php endif; ?>
+    <?php for ($i=max(1,$page-2); $i<=min($pages,$page+2); $i++): ?>
+      <a href="?slug=<?= urlencode($slug) ?>&page=<?= $i ?>" class="pg-btn <?= $i===$page?'active':'' ?>"><?= $i ?></a>
+    <?php endfor; ?>
+    <?php if ($page < $pages): ?><a href="?slug=<?= urlencode($slug) ?>&page=<?= $page+1 ?>" class="pg-btn">Next →</a><?php endif; ?>
+  </nav>
+<?php endif; ?>
+
+<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>
diff --git a/forum/new-topic.php b/forum/new-topic.php
new file mode 100644
index 0000000..e057594
--- /dev/null
+++ b/forum/new-topic.php
@@ -0,0 +1,147 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_login();
+
+$allCats = DB::rows('SELECT * FROM categories ORDER BY position, id');
+$cats    = array_filter($allCats, fn($c) => can_post_topic($c));
+$selCat  = (int)get('cat');
+$errs    = [];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    if (!csrf_ok()) { $errs[] = 'Invalid request.'; }
+    else {
+        $title   = sanitise(post('title'));
+        $content = sanitise(post('content'));
+        $catId   = (int)post('cat_id');
+        $tagStr  = sanitise(post('tags'));
+
+        if (!$title)   $errs[] = 'Title is required.';
+        if (!$content) $errs[] = 'Post content is required.';
+        if (!$catId)   $errs[] = 'Please select a category.';
+        if (strlen($title) > 200) $errs[] = 'Title too long (max 200 chars).';
+        if (strlen($content) > 50000) $errs[] = 'Content too long.';
+
+        if (!$errs && !DB::row('SELECT id FROM categories WHERE id=?', [$catId])) $errs[] = 'Invalid category.';
+        if (!$errs) {
+            $postCat = DB::row('SELECT * FROM categories WHERE id=?', [$catId]);
+            if (!$postCat || !can_post_topic($postCat)) $errs[] = 'You do not have permission to post in this category.';
+        }
+
+        if (!$errs && cfg('topic_captcha_enabled', '0') === '1') {
+            if (!post_captcha_verify(post('topic_captcha'))) $errs[] = 'Incorrect security answer.';
+        }
+
+        if (!$errs) {
+            $rl = rate_check((int)$USER['id'], 'topic');
+            if (!$rl['ok']) {
+                $wait  = (int)$rl['wait'];
+                $errs[] = "Slow down! Please wait {$wait} second" . ($wait !== 1 ? 's' : '') . '.';
+            }
+        }
+
+        if (!$errs) {
+            $slug = unique_slug($title, 'topics');
+            $tid  = DB::insert('INSERT INTO topics (title,slug,category_id,user_id) VALUES (?,?,?,?)',
+                [$title, $slug, $catId, $USER['id']]);
+            $pid  = DB::insert('INSERT INTO posts (topic_id,user_id,content,post_num) VALUES (?,?,?,1)',
+                [$tid, $USER['id'], $content]);
+
+            if ($tagStr) {
+                $names = array_slice(array_filter(array_map('trim', explode(',', mb_strtolower($tagStr)))), 0, 5);
+                foreach ($names as $n) {
+                    if (!$n) continue;
+                    $tg   = DB::row('SELECT id FROM tags WHERE name=?', [$n]);
+                    $tgId = $tg ? $tg['id'] : DB::insert('INSERT INTO tags (name) VALUES (?)', [$n]);
+                    DB::insertIgnore('topic_tags', ['topic_id', 'tag_id'], [$tid, $tgId]);
+                    DB::run('UPDATE tags SET topic_count=topic_count+1 WHERE id=?', [$tgId]);
+                }
+            }
+            DB::run('UPDATE users SET topic_count=topic_count+1,post_count=post_count+1 WHERE id=?', [$USER['id']]);
+            DB::run('UPDATE categories SET topic_count=topic_count+1,post_count=post_count+1 WHERE id=?', [$catId]);
+            process_mentions($content, $pid, $slug, (int)$USER['id'], $USER['username']);
+            rate_record((int)$USER['id'], 'topic');
+            addon_hook('after_topic_created', ['topic_id'=>$tid,'title'=>$title,'slug'=>$slug,'category_id'=>$catId,'user_id'=>(int)$USER['id']]);
+            go('forum/topic.php?slug=' . urlencode($slug));
+        }
+    }
+}
+
+$PAGE_TITLE = 'New Topic';
+include __DIR__ . '/../views/partials/layout.php';
+?>
+<nav class="bc"><a href="<?=u('/')?>">Home</a> › <span>New Topic</span></nav>
+<div class="form-card">
+  <h1>Create a New Topic</h1>
+  <?php if($errs):?><div class="alert err"><?=implode('<br>',array_map('e',$errs))?></div><?php endif;?>
+  <form method="POST" id="ntForm">
+    <?=csrf_input()?>
+    <div class="fg">
+      <label>Category <span class="req">*</span></label>
+      <select name="cat_id" class="fi" required>
+        <option value="">— Select a category —</option>
+        <?php foreach($cats as $c):?>
+          <option value="<?=$c['id']?>" <?=($selCat==$c['id']||((int)($_POST['cat_id']??0))==$c['id'])?'selected':''?>>
+            <?=e($c['icon'])?> <?=e($c['name'])?>
+          </option>
+        <?php endforeach;?>
+      </select>
+    </div>
+    <div class="fg">
+      <label>Title <span class="req">*</span></label>
+      <input type="text" name="title" class="fi" required maxlength="200"
+             value="<?=e($_POST['title']??'')?>" placeholder="What is this about?">
+    </div>
+    <div class="fg">
+      <label>Tags <small>(optional, comma-separated)</small></label>
+      <input type="text" name="tags" id="tagsIn" class="fi" value="<?=e($_POST['tags']??'')?>" placeholder="help, tutorial">
+      <div id="tagPreview" class="tag-preview"></div>
+    </div>
+    <div class="fg">
+      <label>Content <span class="req">*</span></label>
+      <div class="ed-toolbar">
+        <button type="button" onclick="fmt('bold','replyTa')"><b>B</b></button>
+        <button type="button" onclick="fmt('italic','replyTa')"><i>I</i></button>
+        <button type="button" onclick="fmt('link','replyTa')">🔗</button>
+        <button type="button" onclick="fmt('quote','replyTa')" title="Quote"><svg viewBox="0 0 24 24" fill="currentColor" width="13" height="13" style="vertical-align:middle"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg></button>
+        <button type="button" onclick="fmt('codeblock','replyTa')">📄</button>
+        <button type="button" onclick="fmt('heading','replyTa')">H</button>
+        <button type="button" onclick="fmt('ul','replyTa')">≡</button>
+        <div class="ed-sep"></div>
+        <button type="button" onclick="pickImg('ntImg','replyTa')">🖼</button>
+        <input type="file" id="ntImg" accept="image/*" style="display:none" onchange="uploadImg(this,'replyTa')">
+        <div class="ed-sep"></div>
+        <button type="button" id="prevBtn" onclick="togglePreview()">👁 Preview</button>
+      </div>
+      <div class="ed-panes">
+        <textarea id="replyTa" name="content" class="reply-ta" rows="14" required
+                  placeholder="Write your post… Markdown supported.&#10;Tip: use @username to mention someone!"><?=e($_POST['content']??'')?></textarea>
+        <div id="replyPreview" class="reply-preview hidden"></div>
+      </div>
+    </div>
+    <?php if (cfg('topic_captcha_enabled','0') === '1'): ?>
+      <?php $tc = post_captcha_generate(); ?>
+      <div class="fg captcha-box">
+        <label class="captcha-label">🔒 Security — What is <strong class="captcha-q"><?= e($tc['q']) ?></strong></label>
+        <input type="number" name="topic_captcha" class="fi captcha-input" required placeholder="Answer" autocomplete="off" min="-99" max="99">
+        <span class="hint">Solve this math problem to post</span>
+      </div>
+    <?php endif; ?>
+
+    <div class="form-actions">
+      <a href="<?=u('/')?>" class="btn-ghost">Cancel</a>
+      <button type="submit" class="btn-primary btn-lg">Post Topic</button>
+    </div>
+  </form>
+</div>
+<script>
+document.getElementById('tagsIn').addEventListener('input',function(){
+  var tags=this.value.split(',').map(function(t){return t.trim();}).filter(Boolean);
+  document.getElementById('tagPreview').innerHTML=tags.map(function(t){return '<span class="tag">'+escH(t)+'</span>';}).join('');
+});
+function escH(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
+document.getElementById('replyTa').addEventListener('paste',function(e){
+  var items=(e.clipboardData||e.originalEvent.clipboardData).items;
+  for(var i=0;i<items.length;i++){if(items[i].type.indexOf('image')!==-1){e.preventDefault();uploadFileToEditor(items[i].getAsFile(),'replyTa');}}
+});
+</script>
+<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>
diff --git a/forum/search.php b/forum/search.php
new file mode 100644
index 0000000..372cd5f
--- /dev/null
+++ b/forum/search.php
@@ -0,0 +1,192 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+
+$q    = get('q');
+$type = get('type', 'topics'); // topics | users | posts
+if (!in_array($type, ['topics','users','posts'])) $type = 'topics';
+
+$topicResults = [];
+$postResults  = [];
+$userResults  = [];
+
+if (mb_strlen($q) >= 2) {
+    $like = '%' . $q . '%';
+
+    if ($type === 'topics' || $type === 'posts') {
+        // Topics matching title
+        $topicResults = DB::rows("
+            SELECT t.*, u.username, c.name AS cat_name, c.slug AS cat_slug, c.color AS cat_color,
+                   NULL AS match_post_id
+            FROM topics t
+            JOIN users u ON u.id = t.user_id
+            JOIN categories c ON c.id = t.category_id
+            WHERE t.title LIKE ?
+            ORDER BY t.last_post_at DESC LIMIT 20
+        ", [$like]);
+    }
+
+    if ($type === 'posts') {
+        // Posts matching content — include post ID so we can anchor to it
+        $postResults = DB::rows("
+            SELECT p.id AS post_id, p.content, p.post_num, p.created_at AS post_date,
+                   t.id AS topic_id, t.title, t.slug,
+                   u.username, u.avatar,
+                   c.name AS cat_name, c.slug AS cat_slug, c.color AS cat_color
+            FROM posts p
+            JOIN topics t ON t.id = p.topic_id
+            JOIN users u ON u.id = p.user_id
+            JOIN categories c ON c.id = t.category_id
+            WHERE p.content LIKE ? AND p.deleted = 0
+            ORDER BY p.created_at DESC LIMIT 30
+        ", [$like]);
+    }
+
+    if ($type === 'users') {
+        $userResults = DB::rows("
+            SELECT id, username, avatar, role, karma, post_count, bio, joined_at
+            FROM users
+            WHERE (username LIKE ? OR bio LIKE ?) AND suspended = 0
+            ORDER BY post_count DESC LIMIT 30
+        ", [$like, $like]);
+    }
+}
+
+$allCount = count($topicResults) + count($postResults) + count($userResults);
+$PAGE_TITLE = 'Search';
+include __DIR__ . '/../views/partials/layout.php';
+?>
+<div style="max-width:800px">
+
+  <!-- Search form with type tabs -->
+  <form method="GET" id="searchForm">
+    <div style="display:flex;gap:10px;margin-bottom:16px">
+      <div style="position:relative;flex:1;max-width:560px">
+        <svg style="position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--faint);pointer-events:none" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
+        <input type="text" name="q" value="<?= e($q) ?>" class="fi"
+               style="padding-left:34px" placeholder="Search topics, posts, users…"
+               autofocus autocomplete="off" id="mainSearchInput">
+      </div>
+      <button type="submit" class="btn-primary">Search</button>
+    </div>
+
+    <!-- Type tabs -->
+    <div style="display:flex;gap:0;margin-bottom:22px;border-bottom:2px solid var(--border)">
+      <?php foreach (['topics'=>'📋 Topics','posts'=>'💬 Posts','users'=>'👥 Users'] as $t => $lbl): ?>
+        <button type="submit" name="type" value="<?= $t ?>"
+                class="srch-tab <?= $type===$t?'active':'' ?>"><?= $lbl ?></button>
+      <?php endforeach; ?>
+    </div>
+  </form>
+
+  <?php if (mb_strlen($q) >= 2): ?>
+    <p class="search-info">
+      <?= count($type==='posts'?$postResults:($type==='users'?$userResults:$topicResults)) ?>
+      result<?= count($type==='posts'?$postResults:($type==='users'?$userResults:$topicResults))!==1?'s':'' ?>
+      for "<strong><?= e($q) ?></strong>"
+    </p>
+
+    <?php if ($type === 'topics'): ?>
+      <?php if (empty($topicResults)): ?>
+        <div class="empty-state"><p>No topics found matching "<?= e($q) ?>".</p></div>
+      <?php else: ?>
+        <?php foreach ($topicResults as $t): ?>
+          <div class="topic-row">
+            <div class="tr-body">
+              <a href="<?= u('forum/topic.php?slug=' . urlencode($t['slug'])) ?>" class="tr-title"><?= e($t['title']) ?></a>
+              <div class="tr-meta">
+                <a href="<?= u('forum/category.php?slug=' . urlencode($t['cat_slug'])) ?>"
+                   class="cat-tag" style="--cc:<?= e($t['cat_color']) ?>"><?= e($t['cat_name']) ?></a>
+                by <a href="<?= u('users/profile.php?u=' . urlencode($t['username'])) ?>">@<?= e($t['username']) ?></a>
+                <span class="ago" data-ts="<?= e($t['created_at']) ?>"></span>
+              </div>
+            </div>
+            <div class="tr-counts"><span>💬 <?= $t['reply_count'] ?></span><span>👁 <?= $t['views'] ?></span></div>
+          </div>
+        <?php endforeach; ?>
+      <?php endif; ?>
+
+    <?php elseif ($type === 'posts'): ?>
+      <?php if (empty($postResults)): ?>
+        <div class="empty-state"><p>No posts found matching "<?= e($q) ?>".</p></div>
+      <?php else: ?>
+        <?php foreach ($postResults as $p): ?>
+          <?php
+            // Build URL with post anchor so user lands on the exact post
+            $postUrl = u('forum/topic.php?slug=' . urlencode($p['slug']) . '&goto=' . $p['post_id']) . '#post-' . $p['post_id'];
+            // Highlight the search term in content snippet
+            $snippet = mb_substr($p['content'], 0, 200);
+            $pos     = mb_stripos($p['content'], $q);
+            if ($pos !== false) {
+                $start   = max(0, $pos - 60);
+                $snippet = ($start > 0 ? '…' : '') . mb_substr($p['content'], $start, 200) . (mb_strlen($p['content']) > $start+200 ? '…' : '');
+            }
+          ?>
+          <div class="post-search-card">
+            <div class="psc-head">
+              <div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
+                <?php if ($p['avatar']): ?>
+                  <img src="<?= e($p['avatar']) ?>" class="av-sm" alt="">
+                <?php else: ?>
+                  <span class="av-sm av-init"><?= strtoupper($p['username'][0]) ?></span>
+                <?php endif; ?>
+                <div style="min-width:0">
+                  <a href="<?= u('users/profile.php?u='.urlencode($p['username'])) ?>" style="font-weight:600;font-size:13px">@<?= e($p['username']) ?></a>
+                  <span style="color:var(--faint);font-size:12px;margin-left:6px">
+                    in <a href="<?= u('forum/topic.php?slug='.urlencode($p['slug'])) ?>" style="color:var(--muted)"><?= e(mb_substr($p['title'],0,60)) ?><?= mb_strlen($p['title'])>60?'…':'' ?></a>
+                  </span>
+                </div>
+              </div>
+              <div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
+                <span class="ago" data-ts="<?= e($p['post_date']) ?>" style="font-size:12px;color:var(--faint)"></span>
+                <a href="<?= e($postUrl) ?>" class="btn-ghost btn-sm">View Post →</a>
+              </div>
+            </div>
+            <div class="psc-body"><?= e($snippet) ?></div>
+          </div>
+        <?php endforeach; ?>
+      <?php endif; ?>
+
+    <?php elseif ($type === 'users'): ?>
+      <?php if (empty($userResults)): ?>
+        <div class="empty-state"><p>No users found matching "<?= e($q) ?>".</p></div>
+      <?php else: ?>
+        <div class="user-search-grid">
+          <?php foreach ($userResults as $u):
+            $kt = karma_tier((int)$u['karma']); ?>
+            <a href="<?= u('users/profile.php?u='.urlencode($u['username'])) ?>" class="user-search-card">
+              <div class="usc-av">
+                <?php if ($u['avatar']): ?>
+                  <img src="<?= e($u['avatar']) ?>" class="av-lg" alt="">
+                <?php else: ?>
+                  <span class="av-lg av-init"><?= strtoupper($u['username'][0]) ?></span>
+                <?php endif; ?>
+              </div>
+              <div class="usc-body">
+                <div class="usc-name">@<?= e($u['username']) ?> <span class="role-tag role-<?= e($u['role']) ?>"><?= e($u['role']) ?></span></div>
+                <?php if ($u['bio']): ?><div class="usc-bio"><?= e(mb_substr($u['bio'],0,80)) ?></div><?php endif; ?>
+                <div class="usc-stats">
+                  <span><?= number_format($u['post_count']) ?> posts</span>
+                  <span>·</span>
+                  <span style="color:<?= e($kt['color']) ?>"><?= $kt['icon'] ?> <?= number_format((int)$u['karma']) ?> karma</span>
+                </div>
+              </div>
+            </a>
+          <?php endforeach; ?>
+        </div>
+      <?php endif; ?>
+    <?php endif; ?>
+
+  <?php elseif ($q): ?>
+    <div class="alert warn">Please enter at least 2 characters to search.</div>
+  <?php endif; ?>
+</div>
+
+<script>
+// Auto-submit on tab change keeps query
+document.querySelectorAll('.srch-tab').forEach(function(btn){
+  btn.addEventListener('click',function(){
+    document.getElementById('mainSearchInput').form.submit();
+  });
+});
+</script>
+<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>
diff --git a/forum/topic.php b/forum/topic.php
new file mode 100644
index 0000000..a9aecec
--- /dev/null
+++ b/forum/topic.php
@@ -0,0 +1,326 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+
+$slug  = get('slug');
+$topic = DB::row("
+    SELECT t.*, u.username, u.avatar,
+           c.name AS cat_name, c.slug AS cat_slug, c.color AS cat_color
+    FROM topics t
+    JOIN users u ON u.id = t.user_id
+    JOIN categories c ON c.id = t.category_id
+    WHERE t.slug = ?
+", [$slug]);
+if (!$topic) render_404();
+
+// Fetch full category for permission checks
+$topicCat = DB::row('SELECT * FROM categories WHERE id=?', [$topic['category_id']]);
+if (!can_read_category($topicCat ?? [])) render_403();
+
+DB::run('UPDATE topics SET views = views + 1 WHERE id = ?', [$topic['id']]);
+
+$page = max(1, (int)get('page','1'));
+$pp   = max(1, (int)cfg('posts_per_page','20'));
+
+// ?goto=POST_ID — jump directly to a specific post, calculating its page
+$gotoId = (int)get('goto');
+if ($gotoId) {
+    // Find what position this post is in the thread (0-indexed)
+    $postPos = (int)DB::val(
+        'SELECT COUNT(*) FROM posts WHERE topic_id=? AND deleted=0 AND id<=?',
+        [$topic['id'], $gotoId]
+    );
+    if ($postPos > 0) {
+        $targetPage = (int)ceil($postPos / $pp);
+        if ($targetPage !== $page) {
+            // Redirect to the correct page with the anchor
+            $redirectUrl = u('forum/topic.php?slug='.urlencode($slug).'&page='.$targetPage).'#post-'.$gotoId;
+            header('Location: ' . $redirectUrl);
+            exit;
+        }
+        // Already on the right page — anchor will handle scrolling
+        $page = $targetPage;
+    }
+}
+
+$off  = ($page - 1) * $pp;
+
+$posts = DB::rows("
+    SELECT p.*, u.username, u.avatar, u.role, u.post_count, u.karma, u.location,
+           (SELECT COUNT(*) FROM likes l WHERE l.post_id = p.id) AS likes
+    FROM posts p
+    JOIN users u ON u.id = p.user_id
+    WHERE p.topic_id = ? AND p.deleted = 0
+    ORDER BY p.post_num
+    LIMIT ? OFFSET ?
+", [$topic['id'], $pp, $off]);
+
+$total = (int)DB::val('SELECT COUNT(*) FROM posts WHERE topic_id=? AND deleted=0', [$topic['id']]);
+$pages = max(1, (int)ceil($total / $pp));
+
+$tags = DB::rows("
+    SELECT tg.* FROM tags tg
+    JOIN topic_tags tt ON tg.id = tt.tag_id
+    WHERE tt.topic_id = ?
+", [$topic['id']]);
+
+$liked = [];
+if ($USER) {
+    foreach (DB::rows('SELECT post_id FROM likes WHERE user_id=?', [$USER['id']]) as $l)
+        $liked[$l['post_id']] = true;
+}
+
+// Post captcha for reply box
+$post_cap = post_captcha_enabled() ? post_captcha_generate() : null;
+
+$PAGE_TITLE = $topic['title'];
+include __DIR__ . '/../views/partials/layout.php';
+?>
+
+<nav class="bc">
+  <a href="<?= u('/') ?>">Home</a> ›
+  <a href="<?= u('forum/category.php?slug='.urlencode($topic['cat_slug'])) ?>"><?= e($topic['cat_name']) ?></a> ›
+  <span><?= e(mb_substr($topic['title'],0,60)) ?></span>
+</nav>
+
+<!-- Topic header -->
+<div class="topic-hdr">
+  <div class="topic-hdr-main">
+    <div class="topic-badges">
+      <?php if ($topic['pinned']): ?><span class="tbadge pin">📌 Pinned</span><?php endif; ?>
+      <?php if ($topic['closed']): ?><span class="tbadge closed">🔒 Closed</span><?php endif; ?>
+    </div>
+    <h1 class="topic-title"><?= e($topic['title']) ?></h1>
+    <div class="topic-meta">
+      <a href="<?= u('forum/category.php?slug='.urlencode($topic['cat_slug'])) ?>"
+         class="cat-tag" style="--cc:<?= e($topic['cat_color']) ?>"><?= e($topic['cat_name']) ?></a>
+      <?php foreach ($tags as $tg): ?>
+        <span class="tag"><?= e($tg['name']) ?></span>
+      <?php endforeach; ?>
+      <span>·</span>
+      <span><?= max(0, $total - 1) ?> <?= $total === 2 ? 'reply' : 'replies' ?></span>
+      <span>·</span>
+      <span><?= number_format($topic['views']) ?> views</span>
+    </div>
+  </div>
+  <?php if ($USER && is_admin()): ?>
+    <div class="topic-mod">
+      <?php foreach ([
+        [$topic['pinned'],   'unpin',    'pin',       '📌', $topic['pinned']   ? 'Unpin'   : 'Pin'],
+        [$topic['closed'],   'open',     'close',     '🔒', $topic['closed']   ? 'Open'    : 'Close'],
+        [$topic['archived'], 'unarchive','archive',   '📦', $topic['archived'] ? 'Unarchive': 'Archive'],
+      ] as [$state, $onAct, $offAct, $icon, $label]): ?>
+        <form method="POST" action="<?= u('api/topic_action.php') ?>" style="display:inline">
+          <?= csrf_input() ?>
+          <input type="hidden" name="id"     value="<?= $topic['id'] ?>">
+          <input type="hidden" name="action" value="<?= $state ? $onAct : $offAct ?>">
+          <button class="btn-sm btn-ghost"><?= $icon ?> <?= $label ?></button>
+        </form>
+      <?php endforeach; ?>
+    </div>
+  <?php endif; ?>
+</div>
+
+<?php
+/* ── render_topic_header addon hook (collector) ── */
+echo addon_collect('render_topic_header', $topic);
+?>
+
+<!-- Posts list -->
+<?php $postLayoutH = cfg('post_layout_horizontal','0') === '1'; ?>
+<div id="postsList" class="<?= $postLayoutH ? 'posts-horizontal' : '' ?>">
+<?php foreach ($posts as $p): ?>
+  <div class="post <?= $p['post_num'] == 1 ? 'post-op' : '' ?><?= $postLayoutH ? ' post-h' : '' ?>" id="post-<?= $p['id'] ?>">
+
+    <!-- Author sidebar -->
+    <div class="post-side">
+      <?php if ($p['avatar']): ?>
+        <img src="<?= e($p['avatar']) ?>" class="av-lg" alt="">
+      <?php else: ?>
+        <span class="av-lg av-init"><?= strtoupper($p['username'][0]) ?></span>
+      <?php endif; ?>
+      <a href="<?= u('users/profile.php?u='.urlencode($p['username'])) ?>" class="post-name">
+        @<?= e($p['username']) ?>
+      </a>
+      <?php if ($p['role'] === 'admin'):     ?><span class="role-flair admin">Admin</span><?php endif; ?>
+      <?php if ($p['role'] === 'moderator'): ?><span class="role-flair mod">Mod</span><?php endif; ?>
+      <span class="post-pcnt"><?= $p['post_count'] ?> posts</span>
+      <?php if (!empty($p['location'])): ?>
+        <span class="post-location" title="Location">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="9" height="9"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
+          <?= e(mb_substr($p['location'],0,20)) ?>
+        </span>
+      <?php endif; ?>
+      <div class="post-karma-badge">
+        <span class="pkb-icon">
+          <svg viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="10" height="10"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>
+        </span>
+        <span class="pkb-val"><?= number_format((int)$p['karma']) ?></span>
+      </div>
+    </div>
+
+    <!-- Post body -->
+    <div class="post-body">
+      <div class="post-meta-bar">
+        <a class="pnum" href="#post-<?= $p['id'] ?>" title="Permalink to this post">#<?= $p['post_num'] ?></a>
+        <button class="post-link-btn" title="Copy link to this post"
+                onclick="copyPostLink('<?= $p['id'] ?>',this)"
+                data-url="<?= e(rtrim(u('forum/topic.php?slug='.urlencode($slug)), '/')) ?>&amp;goto=<?= $p['id'] ?>">
+          <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
+            <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
+            <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
+          </svg>
+        </button>
+        <time class="ago" data-ts="<?= e($p['created_at']) ?>"></time>
+        <?php if ($p['edited']): ?>
+          <em class="edit-lbl" title="<?= e($p['edit_reason'] ?? '') ?>">(edited)</em>
+        <?php endif; ?>
+        <div class="post-acts">
+          <?php if ($USER): ?>
+            <button class="pa-btn like-btn <?= isset($liked[$p['id']]) ? 'liked' : '' ?>"
+                    onclick="doLike(<?= $p['id'] ?>,this)">
+              ♥ <span class="lc"><?= $p['likes'] ?></span>
+            </button>
+            <?php if (!$topic['closed']): ?>
+              <button class="pa-btn" onclick="doQuote(<?= $p['id'] ?>,'<?= e($p['username']) ?>')">↩ Quote</button>
+            <?php endif; ?>
+            <?php if ($USER['id'] == $p['user_id'] || is_admin()): ?>
+              <button class="pa-btn" onclick="doEdit(<?= $p['id'] ?>)">✏ Edit</button>
+              <button class="pa-btn del" onclick="doDelete(<?= $p['id'] ?>)">🗑 Delete</button>
+            <?php endif; ?>
+          <?php endif; ?>
+        </div>
+      </div>
+
+      <?php if ($p['reply_to']): ?>
+        <div class="reply-ref">
+          ↩ In reply to <a href="#post-<?= $p['reply_to'] ?>">#<?= $p['reply_to'] ?></a>
+        </div>
+      <?php endif; ?>
+
+      <!-- Rendered content — server-side markdown + embeds -->
+      <div class="post-content rendered-post" id="pc-<?= $p['id'] ?>"
+           data-raw="<?= e(base64_encode($p['content'])) ?>">
+        <?= addon_hook('render_post_content', render_post($p['content'])) ?>
+      </div>
+
+      <?php /* ── render_post_footer addon hook (collector) ── */
+      echo addon_collect('render_post_footer', $p);
+      ?>
+
+      <!-- Edit box — hidden by default, toggled by doEdit() -->
+      <div class="edit-box" id="eb-<?= $p['id'] ?>">
+        <div class="ed-toolbar">
+          <button type="button" onclick="fmt('bold','et-<?= $p['id'] ?>')"><b>B</b></button>
+          <button type="button" onclick="fmt('italic','et-<?= $p['id'] ?>')"><i>I</i></button>
+          <button type="button" onclick="fmt('quote','et-<?= $p['id'] ?>')" title="Quote"><svg viewBox="0 0 24 24" fill="currentColor" width="13" height="13" style="vertical-align:middle"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg></button>
+          <button type="button" onclick="pickImg('pi-<?= $p['id'] ?>','et-<?= $p['id'] ?>')">🖼</button>
+          <input type="file" id="pi-<?= $p['id'] ?>" accept="image/*" style="display:none"
+                 onchange="uploadImg(this,'et-<?= $p['id'] ?>')">
+        </div>
+        <textarea id="et-<?= $p['id'] ?>" class="edit-ta fi" rows="7"
+                  placeholder="Edit your post…"></textarea>
+        <input type="text" id="er-<?= $p['id'] ?>" class="fi edit-reason-inp"
+               placeholder="Edit reason (optional)" style="margin-top:6px">
+        <div class="edit-btns">
+          <button class="btn-ghost btn-sm" onclick="cancelEdit(<?= $p['id'] ?>)">Cancel</button>
+          <button class="btn-primary btn-sm" onclick="saveEdit(<?= $p['id'] ?>)">Save Changes</button>
+        </div>
+      </div>
+
+    </div>
+  </div>
+<?php endforeach; ?>
+</div>
+
+<!-- Pagination -->
+<?php if ($pages > 1): ?>
+  <nav class="pager">
+    <?php if ($page > 1): ?>
+      <a href="?slug=<?= urlencode($slug) ?>&page=<?= $page-1 ?>" class="pg-btn">← Prev</a>
+    <?php endif; ?>
+    <?php for ($i = max(1,$page-2); $i <= min($pages,$page+2); $i++): ?>
+      <a href="?slug=<?= urlencode($slug) ?>&page=<?= $i ?>"
+         class="pg-btn <?= $i===$page?'active':'' ?>"><?= $i ?></a>
+    <?php endfor; ?>
+    <?php if ($page < $pages): ?>
+      <a href="?slug=<?= urlencode($slug) ?>&page=<?= $page+1 ?>" class="pg-btn">Next →</a>
+    <?php endif; ?>
+  </nav>
+<?php endif; ?>
+
+<!-- Reply box -->
+<?php if ($USER && !$topic['closed']): ?>
+  <div class="reply-box" id="replyBox">
+    <div class="reply-hdr">
+      <?php if ($USER['avatar']): ?>
+        <img src="<?= e($USER['avatar']) ?>" class="av-md" alt="">
+      <?php else: ?>
+        <span class="av-md av-init"><?= strtoupper($USER['username'][0]) ?></span>
+      <?php endif; ?>
+      <span>Reply as <strong>@<?= e($USER['username']) ?></strong></span>
+    </div>
+
+    <div class="ed-toolbar">
+      <button type="button" onclick="fmt('bold')"><b>B</b></button>
+      <button type="button" onclick="fmt('italic')"><i>I</i></button>
+      <button type="button" onclick="fmt('link')">🔗</button>
+      <button type="button" onclick="fmt('quote')" title="Quote"><svg viewBox="0 0 24 24" fill="currentColor" width="13" height="13" style="vertical-align:middle"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg></button>
+      <button type="button" onclick="fmt('codeblock')">📄</button>
+      <button type="button" onclick="fmt('heading')">H</button>
+      <button type="button" onclick="fmt('ul')">≡</button>
+      <div class="ed-sep"></div>
+      <button type="button" onclick="pickImg('mainImg','replyTa')">🖼</button>
+      <input type="file" id="mainImg" accept="image/*" style="display:none"
+             onchange="uploadImg(this,'replyTa')">
+      <div class="ed-sep"></div>
+      <button type="button" id="prevBtn" onclick="togglePreview()">👁 Preview</button>
+    </div>
+
+    <div class="ed-panes">
+      <textarea id="replyTa" class="reply-ta"
+                placeholder="Write your reply… Markdown supported.&#10;Paste or drag images to embed.&#10;Type @username to mention someone."
+                rows="8"></textarea>
+      <div id="replyPreview" class="reply-preview hidden"></div>
+    </div>
+
+    <div class="reply-footer">
+      <span class="hint">Markdown · Images · @mentions · Auto-embeds YouTube &amp; more</span>
+      <div class="reply-footer-right">
+        <span id="charCnt" class="cnt">0</span>
+        <button class="btn-primary" id="replyBtn"
+                onclick="sendReply('<?= e($topic['slug']) ?>')">Post Reply</button>
+      </div>
+    </div>
+
+    <?php if ($post_cap): ?>
+    <div class="post-captcha-row" id="postCaptchaRow">
+      <span class="post-captcha-label">
+        🔒 Security — What is <strong class="captcha-q"><?= e($post_cap['q']) ?></strong>
+      </span>
+      <input type="number" id="postCaptchaInput" class="fi post-captcha-inp"
+             placeholder="?" autocomplete="off" min="-99" max="99">
+      <span class="hint">Solve to post</span>
+    </div>
+    <?php endif; ?>
+
+    <div class="rate-limit-info" id="rateLimitInfo" style="display:none">
+      <div class="rate-limit-bar">
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
+        <span id="rateLimitMsg">Please wait before posting again:</span>
+        <div class="rate-countdown" id="rateCountdown"></div>
+      </div>
+    </div>
+  </div>
+
+<?php elseif (!$USER): ?>
+  <div class="reply-cta">
+    <p>Join the discussion!</p>
+    <a href="<?= u('auth/login.php') ?>" class="btn-ghost">Log In</a>
+    <a href="<?= u('auth/register.php') ?>" class="btn-primary">Sign Up to Reply</a>
+  </div>
+
+<?php else: ?>
+  <div class="closed-notice">🔒 This topic is closed to new replies.</div>
+<?php endif; ?>
+
+<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>
diff --git a/includes/addons.php b/includes/addons.php
new file mode 100644
index 0000000..d88fa89
--- /dev/null
+++ b/includes/addons.php
@@ -0,0 +1,176 @@
+<?php
+/**
+ * Nexus Forum — Addon System
+ *
+ * Two hook patterns:
+ *
+ *  AddonManager::fire($hook, $data)
+ *    FILTER pattern — each callback receives the return value of the previous.
+ *    Use for transforming a single value (e.g. render_post_content: HTML string in/out).
+ *
+ *  AddonManager::collect($hook, $context)
+ *    COLLECTOR pattern — every callback receives the same $context array.
+ *    Non-empty string returns are accumulated and joined.
+ *    Use for output hooks where multiple addons each contribute HTML
+ *    (e.g. render_post_footer, render_topic_header, before_page_head).
+ */
+if (!defined('NEXUS')) exit('Forbidden');
+
+class AddonManager {
+    private static array  $hooks   = [];
+    private static array  $loaded  = [];
+    private static ?array $active  = null;
+
+    /* ── Boot all active addons ─────────────────────────────── */
+    public static function boot(): void {
+        foreach (self::activeList() as $slug) {
+            $main = ROOT . '/addons/' . $slug . '/main.php';
+            if (file_exists($main) && !isset(self::$loaded[$slug])) {
+                try {
+                    require_once $main;
+                    self::$loaded[$slug] = true;
+                } catch (Throwable $e) {
+                    error_log("Addon [$slug] boot error: " . $e->getMessage());
+                }
+            }
+        }
+    }
+
+    /* ── Register a hook callback ────────────────────────────── */
+    public static function on(string $hook, callable $cb, int $priority = 10): void {
+        self::$hooks[$hook][$priority][] = $cb;
+    }
+
+    /* ── FILTER: chain callbacks, each receives previous return ─ */
+    public static function fire(string $hook, mixed $data = null): mixed {
+        if (empty(self::$hooks[$hook])) return $data;
+        $buckets = self::$hooks[$hook];
+        ksort($buckets);
+        foreach ($buckets as $callbacks) {
+            foreach ($callbacks as $cb) {
+                try {
+                    $result = $cb($data);
+                    if ($result !== null) $data = $result;
+                } catch (Throwable $e) {
+                    error_log("Addon hook [$hook] filter error: " . $e->getMessage());
+                }
+            }
+        }
+        return $data;
+    }
+
+    /* ── COLLECTOR: all callbacks receive same $context ──────── */
+    /* Each callback should return '' or an HTML string to append. */
+    public static function collect(string $hook, mixed $context = null): string {
+        if (empty(self::$hooks[$hook])) return '';
+        $buckets = self::$hooks[$hook];
+        ksort($buckets);
+        $output = '';
+        foreach ($buckets as $callbacks) {
+            foreach ($callbacks as $cb) {
+                try {
+                    $result = $cb($context);
+                    if (is_string($result) && $result !== '') {
+                        $output .= $result;
+                    }
+                } catch (Throwable $e) {
+                    error_log("Addon hook [$hook] collect error: " . $e->getMessage());
+                }
+            }
+        }
+        return $output;
+    }
+
+    public static function hasHook(string $hook): bool {
+        return !empty(self::$hooks[$hook]);
+    }
+
+    /* ── Active addon list ──────────────────────────────────── */
+    public static function activeList(): array {
+        if (self::$active !== null) return self::$active;
+        try {
+            $val = DB::val("SELECT value FROM settings WHERE `key`='active_addons'");
+            self::$active = $val ? (json_decode($val, true) ?: []) : [];
+        } catch (Throwable $e) {
+            self::$active = [];
+        }
+        return self::$active;
+    }
+
+    /* ── Scan all installed addons ──────────────────────────── */
+    public static function all(): array {
+        $dir    = ROOT . '/addons';
+        $active = self::activeList();
+        $addons = [];
+        if (!is_dir($dir)) return [];
+        foreach (scandir($dir) as $slug) {
+            if ($slug[0] === '.') continue;
+            $manifest = $dir . '/' . $slug . '/nexus-addon.json';
+            if (!file_exists($manifest)) continue;
+            $info = json_decode(file_get_contents($manifest), true);
+            if (!$info || empty($info['name'])) continue;
+            $addons[$slug] = array_merge([
+                'slug' => $slug, 'name' => $slug, 'description' => '',
+                'version' => '1.0.0', 'author' => 'Unknown', 'url' => '',
+                'hooks' => [], 'requires' => [],
+            ], $info, [
+                'slug'   => $slug,
+                'active' => in_array($slug, $active),
+            ]);
+        }
+        return $addons;
+    }
+
+    /* ── Activate / deactivate ──────────────────────────────── */
+    public static function activate(string $slug): bool {
+        $active = self::activeList();
+        if (in_array($slug, $active)) return true;
+        $install = ROOT . '/addons/' . $slug . '/install.php';
+        if (file_exists($install)) {
+            try { require $install; } catch (Throwable $e) {
+                error_log("Addon [$slug] install error: " . $e->getMessage());
+                return false;
+            }
+        }
+        $active[] = $slug;
+        self::saveActive($active);
+        self::$active = $active;
+        return true;
+    }
+
+    public static function deactivate(string $slug): bool {
+        $active = self::activeList();
+        if (!in_array($slug, $active)) return true;
+        $uninstall = ROOT . '/addons/' . $slug . '/uninstall.php';
+        if (file_exists($uninstall)) {
+            try { require $uninstall; } catch (Throwable $e) {
+                error_log("Addon [$slug] uninstall error: " . $e->getMessage());
+            }
+        }
+        $active = array_values(array_filter($active, fn($s) => $s !== $slug));
+        self::saveActive($active);
+        self::$active = $active;
+        return true;
+    }
+
+    private static function saveActive(array $active): void {
+        DB::upsert('settings', 'key', 'value', 'active_addons', json_encode(array_values($active)));
+    }
+}
+
+/* ── Global convenience helpers ─────────────────────────────── */
+
+/** FILTER hook — transforms a value through all callbacks */
+function addon_hook(string $hook, mixed $data = null): mixed {
+    return AddonManager::fire($hook, $data);
+}
+
+/** COLLECTOR hook — every callback gets same $context, returns accumulated HTML */
+function addon_collect(string $hook, mixed $context = null): string {
+    return AddonManager::collect($hook, $context);
+}
+
+/** Register a callback on a hook */
+function addon_on(string $hook, callable $cb, int $priority = 10): void {
+    AddonManager::on($hook, $cb, $priority);
+}
diff --git a/includes/bootstrap.php b/includes/bootstrap.php
new file mode 100644
index 0000000..917bc8f
--- /dev/null
+++ b/includes/bootstrap.php
@@ -0,0 +1,40 @@
+<?php
+define('NEXUS', true);
+
+require_once __DIR__ . '/config.php';
+require_once __DIR__ . '/db.php';
+require_once __DIR__ . '/functions.php';
+
+start_session();
+
+// Redirect to installer if not installed
+if (!file_exists(DATA . '/installed.lock')) {
+    $uri = $_SERVER['REQUEST_URI'] ?? '';
+    if (strpos($uri, '/install') === false) {
+        header('Location: ' . BASE . '/install/');
+        exit;
+    }
+}
+
+// Auto-migrate: ensure all tables exist for upgraded installs.
+// CREATE TABLE IF NOT EXISTS is idempotent — safe to run on every request.
+// This fixes "table not found" errors when upgrading from older versions.
+static $_dbInited = false;
+if (!$_dbInited) {
+    try {
+        DB::init();
+        $_dbInited = true;
+    } catch (Throwable $e) {
+        // Log but don't crash — if DB is unreachable the next query will fail with a clearer error
+        error_log('Nexus DB::init() failed: ' . $e->getMessage());
+    }
+}
+
+require_once __DIR__ . '/markdown.php';
+require_once __DIR__ . '/addons.php';
+
+// Set global current user
+$USER = current_user();
+
+// Boot addons after user is set
+AddonManager::boot();
diff --git a/includes/config.php b/includes/config.php
new file mode 100644
index 0000000..3a4c4da
--- /dev/null
+++ b/includes/config.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Nexus Forum — Core Configuration
+ * Auto-detects base path. Loads db_config.php if present.
+ */
+if (!defined('NEXUS')) { http_response_code(403); exit('Forbidden'); }
+
+/* ── Paths ──────────────────────────────────────────────────── */
+define('ROOT',    dirname(__DIR__));
+define('DATA',    ROOT . '/data');
+define('UPLOADS', ROOT . '/public/uploads');
+
+/* ── Base URL detection ─────────────────────────────────────── */
+if (!defined('BASE')) {
+    $docRoot  = rtrim(str_replace('\\', '/', $_SERVER['DOCUMENT_ROOT'] ?? ''), '/');
+    $rootPath = str_replace('\\', '/', ROOT);
+    $base     = str_replace($docRoot, '', $rootPath);
+    $base     = '/' . trim($base, '/');
+    define('BASE', $base === '/' ? '' : $base);
+}
+
+/* ── Database config (written by installer) ─────────────────── */
+$dbCfg = ROOT . '/includes/db_config.php';
+if (file_exists($dbCfg)) {
+    require_once $dbCfg;
+} else {
+    // Default: SQLite (installer not yet run, or config missing)
+    if (!defined('DB_DRIVER')) define('DB_DRIVER', 'sqlite');
+}
+
+/* ── Security headers (applied on every page load) ──────────── */
+if (!headers_sent()) {
+    header('X-Content-Type-Options: nosniff');
+    header('X-Frame-Options: SAMEORIGIN');
+    header('X-XSS-Protection: 1; mode=block');
+    header('Referrer-Policy: strict-origin-when-cross-origin');
+    header('Permissions-Policy: camera=(), microphone=(), geolocation=()');
+
+    // Content-Security-Policy — blocks injected scripts even if XSS were possible.
+    // script-src 'self' allows our own JS; cdnjs for Prism.js; 'unsafe-inline' for
+    // onclick= attributes used throughout the forum (toolbar buttons, embeds etc.).
+    // frame-src allows trusted embed domains only.
+    $csp = implode('; ', [
+        "default-src 'self'",
+        "script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://platform.twitter.com",
+        "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com",
+        "font-src 'self' https://fonts.gstatic.com",
+        "img-src 'self' data: https:",
+        "media-src 'self' https:",
+        "frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://player.twitch.tv https://clips.twitch.tv https://open.spotify.com https://w.soundcloud.com https://soundcloud.com https://bandcamp.com https://codepen.io https://jsfiddle.net https://www.loom.com https://rumble.com https://embed.ted.com https://www.dailymotion.com https://streamable.com https://platform.twitter.com https://syndication.twitter.com",
+        "connect-src 'self'",
+        "object-src 'none'",
+        "base-uri 'self'",
+        "form-action 'self'",
+    ]);
+    header('Content-Security-Policy: ' . $csp);
+    // Only set HSTS if running HTTPS
+    if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
+        header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
+    }
+}
+
+/* ── Error handling ──────────────────────────────────────────── */
+// Production: hide errors from users, log them instead
+if (!(defined('NEXUS_DEBUG') && NEXUS_DEBUG)) {
+    ini_set('display_errors', '0');
+    ini_set('log_errors', '1');
+    error_reporting(E_ALL);
+}
diff --git a/includes/db.php b/includes/db.php
new file mode 100644
index 0000000..0724fcc
--- /dev/null
+++ b/includes/db.php
@@ -0,0 +1,582 @@
+<?php
+if (!defined('NEXUS')) exit('Forbidden');
+
+/**
+ * Database abstraction — supports SQLite3, MySQL, and MariaDB.
+ * Driver is selected from DB_DRIVER constant (set by installer or config.php).
+ *
+ *   SQLite:  DB_DRIVER='sqlite'  (default, no credentials needed)
+ *   MySQL:   DB_DRIVER='mysql'   + DB_HOST, DB_NAME, DB_USER, DB_PASS, DB_PORT
+ *   MariaDB: DB_DRIVER='mysql'   (same driver as MySQL via PDO)
+ */
+class DB {
+    private static ?PDO $pdo = null;
+
+    /* ── Connect ──────────────────────────────────────────────── */
+    public static function connect(): PDO {
+        if (self::$pdo !== null) return self::$pdo;
+
+        $driver = defined('DB_DRIVER') ? DB_DRIVER : 'sqlite';
+
+        if ($driver === 'mysql') {
+            $host    = defined('DB_HOST') ? DB_HOST : '127.0.0.1';
+            $port    = defined('DB_PORT') ? DB_PORT : '3306';
+            $name    = defined('DB_NAME') ? DB_NAME : 'nexus';
+            $user    = defined('DB_USER') ? DB_USER : 'root';
+            $pass    = defined('DB_PASS') ? DB_PASS : '';
+            $charset = defined('DB_CHARSET') ? DB_CHARSET : 'utf8mb4';
+
+            $dsn = "mysql:host={$host};port={$port};dbname={$name};charset={$charset}";
+            self::$pdo = new PDO($dsn, $user, $pass, [
+                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
+                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+                PDO::ATTR_EMULATE_PREPARES   => false,
+                PDO::MYSQL_ATTR_FOUND_ROWS   => true,
+            ]);
+            self::$pdo->exec("SET SESSION sql_mode='STRICT_TRANS_TABLES,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO'");
+        } else {
+            // SQLite
+            $path = DATA . '/forum.db';
+            if (!is_dir(DATA)) mkdir(DATA, 0750, true);
+            self::$pdo = new PDO('sqlite:' . $path, null, null, [
+                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
+                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+            ]);
+            self::$pdo->exec('PRAGMA foreign_keys = ON');
+            self::$pdo->exec('PRAGMA journal_mode = WAL');
+            self::$pdo->exec('PRAGMA synchronous = NORMAL');
+            self::$pdo->exec('PRAGMA temp_store = MEMORY');
+            self::$pdo->exec('PRAGMA mmap_size = 268435456');
+        }
+
+        return self::$pdo;
+    }
+
+    public static function driver(): string {
+        return defined('DB_DRIVER') ? DB_DRIVER : 'sqlite';
+    }
+
+    public static function isMysql(): bool {
+        return self::driver() === 'mysql';
+    }
+
+    /* ── Query helpers ────────────────────────────────────────── */
+    public static function run(string $sql, array $p = []): PDOStatement {
+        $s = self::connect()->prepare($sql);
+        $s->execute($p);
+        return $s;
+    }
+
+    public static function row(string $sql, array $p = []): ?array {
+        $r = self::run($sql, $p)->fetch();
+        return $r ?: null;
+    }
+
+    public static function rows(string $sql, array $p = []): array {
+        return self::run($sql, $p)->fetchAll();
+    }
+
+    public static function insert(string $sql, array $p = []): int {
+        self::run($sql, $p);
+        return (int) self::connect()->lastInsertId();
+    }
+
+    public static function val(string $sql, array $p = []): mixed {
+        $row = self::run($sql, $p)->fetch(PDO::FETCH_NUM);
+        return $row ? $row[0] : null;
+    }
+
+    /**
+     * Cross-driver INSERT IGNORE.
+     * Silently skips if the row already exists (duplicate key).
+     */
+    public static function insertIgnore(string $table, array $cols, array $vals): void {
+        $placeholders = implode(',', array_fill(0, count($vals), '?'));
+        $colList      = implode(',', array_map(fn($c) => "`$c`", $cols));
+        if (self::isMysql()) {
+            self::run("INSERT IGNORE INTO `{$table}` ({$colList}) VALUES ({$placeholders})", $vals);
+        } else {
+            self::run("INSERT OR IGNORE INTO {$table} (" . implode(',', $cols) . ") VALUES ({$placeholders})", $vals);
+        }
+    }
+
+    /**
+     * Cross-driver upsert (INSERT ... ON DUPLICATE KEY UPDATE for MySQL,
+     * INSERT OR REPLACE for SQLite).
+     * Only works for simple single-column key tables like settings(key,value).
+     */
+    public static function upsert(string $table, string $keyCol, string $valCol, string $key, string $val): void {
+        if (self::isMysql()) {
+            self::run(
+                "INSERT INTO `{$table}` (`{$keyCol}`,`{$valCol}`) VALUES (?,?) ON DUPLICATE KEY UPDATE `{$valCol}`=VALUES(`{$valCol}`)",
+                [$key, $val]
+            );
+        } else {
+            self::run(
+                "INSERT OR REPLACE INTO {$table} ({$keyCol},{$valCol}) VALUES (?,?)",
+                [$key, $val]
+            );
+        }
+    }
+
+    /* ── Schema: cross-driver table creation ─────────────────── */
+    public static function init(): void {
+        $mysql = self::isMysql();
+
+        /* Helper: datetime default compatible with both drivers */
+        $now   = $mysql ? "DEFAULT CURRENT_TIMESTAMP" : "DEFAULT (datetime('now'))";
+        $auto  = $mysql ? "INT AUTO_INCREMENT"   : "INTEGER PRIMARY KEY AUTOINCREMENT";
+        $pk    = $mysql ? "PRIMARY KEY"          : "";
+        $eng   = $mysql ? "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" : "";
+
+        $tables = [];
+
+        /* settings */
+        $tables[] = "CREATE TABLE IF NOT EXISTS settings (
+            `key`   VARCHAR(100) PRIMARY KEY,
+            `value` TEXT NOT NULL
+        ) $eng";
+
+        /* users */
+        if ($mysql) {
+            $tables[] = "CREATE TABLE IF NOT EXISTS users (
+                id             INT AUTO_INCREMENT PRIMARY KEY,
+                username       VARCHAR(30)  NOT NULL,
+                email          VARCHAR(254) NOT NULL,
+                password       VARCHAR(255) NOT NULL,
+                avatar         TEXT,
+                bio            TEXT,
+                role           VARCHAR(20)  NOT NULL DEFAULT 'member',
+                permissions    TEXT         NOT NULL DEFAULT '{}',
+                post_count     INT          NOT NULL DEFAULT 0,
+                topic_count    INT          NOT NULL DEFAULT 0,
+                karma          INT          NOT NULL DEFAULT 0,
+                suspended      TINYINT      NOT NULL DEFAULT 0,
+                silenced       TINYINT      NOT NULL DEFAULT 0,
+                location       VARCHAR(100) NOT NULL DEFAULT '',
+                friends_hidden TINYINT      NOT NULL DEFAULT 0,
+                joined_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                last_seen      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                UNIQUE KEY uq_username (username),
+                UNIQUE KEY uq_email (email)
+            ) $eng";
+        } else {
+            $tables[] = "CREATE TABLE IF NOT EXISTS users (
+                id             INTEGER PRIMARY KEY AUTOINCREMENT,
+                username       TEXT    NOT NULL UNIQUE,
+                email          TEXT    NOT NULL UNIQUE,
+                password       TEXT    NOT NULL,
+                avatar         TEXT,
+                bio            TEXT,
+                role           TEXT    NOT NULL DEFAULT 'member',
+                permissions    TEXT    NOT NULL DEFAULT '{}',
+                post_count     INTEGER NOT NULL DEFAULT 0,
+                topic_count    INTEGER NOT NULL DEFAULT 0,
+                karma          INTEGER NOT NULL DEFAULT 0,
+                suspended      INTEGER NOT NULL DEFAULT 0,
+                silenced       INTEGER NOT NULL DEFAULT 0,
+                location       TEXT    NOT NULL DEFAULT '',
+                friends_hidden INTEGER NOT NULL DEFAULT 0,
+                joined_at      TEXT    NOT NULL DEFAULT (datetime('now')),
+                last_seen      TEXT    NOT NULL DEFAULT (datetime('now'))
+            )";
+        }
+
+        /* categories */
+        if ($mysql) {
+            $tables[] = "CREATE TABLE IF NOT EXISTS categories (
+                id          INT AUTO_INCREMENT PRIMARY KEY,
+                name        VARCHAR(100) NOT NULL,
+                slug        VARCHAR(100) NOT NULL,
+                description TEXT         NOT NULL DEFAULT '',
+                color       VARCHAR(20)  NOT NULL DEFAULT '#3b82f6',
+                icon        VARCHAR(10)  NOT NULL DEFAULT '💬',
+                position    INT          NOT NULL DEFAULT 0,
+                parent_id   INT,
+                topic_count INT          NOT NULL DEFAULT 0,
+                post_count  INT          NOT NULL DEFAULT 0,
+                read_role   VARCHAR(20)  NOT NULL DEFAULT 'guest',
+                post_role   VARCHAR(20)  NOT NULL DEFAULT 'member',
+                reply_role  VARCHAR(20)  NOT NULL DEFAULT 'member',
+                UNIQUE KEY uq_slug (slug)
+            ) $eng";
+        } else {
+            $tables[] = "CREATE TABLE IF NOT EXISTS categories (
+                id          INTEGER PRIMARY KEY AUTOINCREMENT,
+                name        TEXT    NOT NULL,
+                slug        TEXT    NOT NULL UNIQUE,
+                description TEXT    NOT NULL DEFAULT '',
+                color       TEXT    NOT NULL DEFAULT '#3b82f6',
+                icon        TEXT    NOT NULL DEFAULT '💬',
+                position    INTEGER NOT NULL DEFAULT 0,
+                parent_id   INTEGER,
+                topic_count INTEGER NOT NULL DEFAULT 0,
+                post_count  INTEGER NOT NULL DEFAULT 0,
+                read_role   TEXT    NOT NULL DEFAULT 'guest',
+                post_role   TEXT    NOT NULL DEFAULT 'member',
+                reply_role  TEXT    NOT NULL DEFAULT 'member'
+            )";
+        }
+
+        /* topics */
+        if ($mysql) {
+            $tables[] = "CREATE TABLE IF NOT EXISTS topics (
+                id           INT AUTO_INCREMENT PRIMARY KEY,
+                title        VARCHAR(200) NOT NULL,
+                slug         VARCHAR(220) NOT NULL,
+                category_id  INT          NOT NULL,
+                user_id      INT          NOT NULL,
+                views        INT          NOT NULL DEFAULT 0,
+                reply_count  INT          NOT NULL DEFAULT 0,
+                pinned       TINYINT      NOT NULL DEFAULT 0,
+                closed       TINYINT      NOT NULL DEFAULT 0,
+                archived     TINYINT      NOT NULL DEFAULT 0,
+                last_post_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                created_at   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                UNIQUE KEY uq_slug (slug),
+                KEY ix_cat (category_id),
+                KEY ix_user (user_id)
+            ) $eng";
+        } else {
+            $tables[] = "CREATE TABLE IF NOT EXISTS topics (
+                id           INTEGER PRIMARY KEY AUTOINCREMENT,
+                title        TEXT    NOT NULL,
+                slug         TEXT    NOT NULL UNIQUE,
+                category_id  INTEGER NOT NULL,
+                user_id      INTEGER NOT NULL,
+                views        INTEGER NOT NULL DEFAULT 0,
+                reply_count  INTEGER NOT NULL DEFAULT 0,
+                pinned       INTEGER NOT NULL DEFAULT 0,
+                closed       INTEGER NOT NULL DEFAULT 0,
+                archived     INTEGER NOT NULL DEFAULT 0,
+                last_post_at TEXT    NOT NULL DEFAULT (datetime('now')),
+                created_at   TEXT    NOT NULL DEFAULT (datetime('now'))
+            )";
+        }
+
+        /* posts */
+        if ($mysql) {
+            $tables[] = "CREATE TABLE IF NOT EXISTS posts (
+                id          INT AUTO_INCREMENT PRIMARY KEY,
+                topic_id    INT      NOT NULL,
+                user_id     INT      NOT NULL,
+                content     TEXT     NOT NULL,
+                post_num    INT      NOT NULL,
+                reply_to    INT,
+                likes       INT      NOT NULL DEFAULT 0,
+                edited      TINYINT  NOT NULL DEFAULT 0,
+                edit_reason VARCHAR(200),
+                deleted     TINYINT  NOT NULL DEFAULT 0,
+                created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                KEY ix_topic (topic_id),
+                KEY ix_user  (user_id)
+            ) $eng";
+        } else {
+            $tables[] = "CREATE TABLE IF NOT EXISTS posts (
+                id          INTEGER PRIMARY KEY AUTOINCREMENT,
+                topic_id    INTEGER NOT NULL,
+                user_id     INTEGER NOT NULL,
+                content     TEXT    NOT NULL,
+                post_num    INTEGER NOT NULL,
+                reply_to    INTEGER,
+                likes       INTEGER NOT NULL DEFAULT 0,
+                edited      INTEGER NOT NULL DEFAULT 0,
+                edit_reason TEXT,
+                deleted     INTEGER NOT NULL DEFAULT 0,
+                created_at  TEXT    NOT NULL DEFAULT (datetime('now')),
+                updated_at  TEXT    NOT NULL DEFAULT (datetime('now'))
+            )";
+        }
+
+        /* likes */
+        if ($mysql) {
+            $tables[] = "CREATE TABLE IF NOT EXISTS likes (
+                post_id INT NOT NULL,
+                user_id INT NOT NULL,
+                PRIMARY KEY (post_id, user_id)
+            ) $eng";
+        } else {
+            $tables[] = "CREATE TABLE IF NOT EXISTS likes (
+                post_id INTEGER NOT NULL,
+                user_id INTEGER NOT NULL,
+                PRIMARY KEY (post_id, user_id)
+            )";
+        }
+
+        /* tags + topic_tags */
+        if ($mysql) {
+            $tables[] = "CREATE TABLE IF NOT EXISTS tags (
+                id          INT AUTO_INCREMENT PRIMARY KEY,
+                name        VARCHAR(50) NOT NULL,
+                topic_count INT         NOT NULL DEFAULT 0,
+                UNIQUE KEY uq_name (name)
+            ) $eng";
+            $tables[] = "CREATE TABLE IF NOT EXISTS topic_tags (
+                topic_id INT NOT NULL,
+                tag_id   INT NOT NULL,
+                PRIMARY KEY (topic_id, tag_id)
+            ) $eng";
+        } else {
+            $tables[] = "CREATE TABLE IF NOT EXISTS tags (
+                id          INTEGER PRIMARY KEY AUTOINCREMENT,
+                name        TEXT    NOT NULL UNIQUE,
+                topic_count INTEGER NOT NULL DEFAULT 0
+            )";
+            $tables[] = "CREATE TABLE IF NOT EXISTS topic_tags (
+                topic_id INTEGER NOT NULL,
+                tag_id   INTEGER NOT NULL,
+                PRIMARY KEY (topic_id, tag_id)
+            )";
+        }
+
+        /* notifications */
+        if ($mysql) {
+            $tables[] = "CREATE TABLE IF NOT EXISTS notifications (
+                id         INT AUTO_INCREMENT PRIMARY KEY,
+                user_id    INT         NOT NULL,
+                type       VARCHAR(50) NOT NULL,
+                payload    TEXT        NOT NULL,
+                `read`     TINYINT     NOT NULL DEFAULT 0,
+                created_at DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                KEY ix_user (user_id)
+            ) $eng";
+        } else {
+            $tables[] = "CREATE TABLE IF NOT EXISTS notifications (
+                id         INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id    INTEGER NOT NULL,
+                type       TEXT    NOT NULL,
+                payload    TEXT    NOT NULL DEFAULT '{}',
+                read       INTEGER NOT NULL DEFAULT 0,
+                created_at TEXT    NOT NULL DEFAULT (datetime('now'))
+            )";
+        }
+
+        /* friends */
+        if ($mysql) {
+            $tables[] = "CREATE TABLE IF NOT EXISTS friends (
+                id         INT AUTO_INCREMENT PRIMARY KEY,
+                user_id    INT         NOT NULL,
+                friend_id  INT         NOT NULL,
+                status     VARCHAR(20) NOT NULL DEFAULT 'pending',
+                created_at DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                UNIQUE KEY uq_pair (user_id, friend_id),
+                KEY ix_uid (user_id),
+                KEY ix_fid (friend_id)
+            ) $eng";
+        } else {
+            $tables[] = "CREATE TABLE IF NOT EXISTS friends (
+                id         INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id    INTEGER NOT NULL,
+                friend_id  INTEGER NOT NULL,
+                status     TEXT    NOT NULL DEFAULT 'pending',
+                created_at TEXT    NOT NULL DEFAULT (datetime('now')),
+                UNIQUE(user_id, friend_id)
+            )";
+        }
+
+        /* messages */
+        if ($mysql) {
+            $tables[] = "CREATE TABLE IF NOT EXISTS messages (
+                id                  INT AUTO_INCREMENT PRIMARY KEY,
+                sender_id           INT          NOT NULL,
+                receiver_id         INT          NOT NULL,
+                subject             VARCHAR(150) NOT NULL DEFAULT '',
+                body                TEXT         NOT NULL,
+                is_read             TINYINT      NOT NULL DEFAULT 0,
+                deleted_by_sender   TINYINT      NOT NULL DEFAULT 0,
+                deleted_by_receiver TINYINT      NOT NULL DEFAULT 0,
+                created_at          DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                KEY ix_recv (receiver_id),
+                KEY ix_send (sender_id)
+            ) $eng";
+        } else {
+            $tables[] = "CREATE TABLE IF NOT EXISTS messages (
+                id                  INTEGER PRIMARY KEY AUTOINCREMENT,
+                sender_id           INTEGER NOT NULL,
+                receiver_id         INTEGER NOT NULL,
+                subject             TEXT    NOT NULL DEFAULT '',
+                body                TEXT    NOT NULL,
+                is_read             INTEGER NOT NULL DEFAULT 0,
+                deleted_by_sender   INTEGER NOT NULL DEFAULT 0,
+                deleted_by_receiver INTEGER NOT NULL DEFAULT 0,
+                created_at          TEXT    NOT NULL DEFAULT (datetime('now'))
+            )";
+        }
+
+        /* rate_events */
+        if ($mysql) {
+            $tables[] = "CREATE TABLE IF NOT EXISTS rate_events (
+                id         INT AUTO_INCREMENT PRIMARY KEY,
+                user_id    INT         NOT NULL,
+                event_type VARCHAR(20) NOT NULL DEFAULT 'post',
+                created_at DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                KEY ix_rate (user_id, event_type, created_at)
+            ) $eng";
+        } else {
+            $tables[] = "CREATE TABLE IF NOT EXISTS rate_events (
+                id         INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id    INTEGER NOT NULL,
+                event_type TEXT    NOT NULL DEFAULT 'post',
+                created_at TEXT    NOT NULL DEFAULT (datetime('now'))
+            )";
+        }
+
+        /* themes */
+        if ($mysql) {
+            $tables[] = "CREATE TABLE IF NOT EXISTS themes (
+                id         INT AUTO_INCREMENT PRIMARY KEY,
+                name       VARCHAR(100) NOT NULL,
+                slug       VARCHAR(100) NOT NULL,
+                css        LONGTEXT     NOT NULL DEFAULT '',
+                is_active  TINYINT      NOT NULL DEFAULT 0,
+                created_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                UNIQUE KEY uq_slug (slug)
+            ) $eng";
+        } else {
+            $tables[] = "CREATE TABLE IF NOT EXISTS themes (
+                id         INTEGER PRIMARY KEY AUTOINCREMENT,
+                name       TEXT    NOT NULL,
+                slug       TEXT    NOT NULL UNIQUE,
+                css        TEXT    NOT NULL DEFAULT '',
+                is_active  INTEGER NOT NULL DEFAULT 0,
+                created_at TEXT    NOT NULL DEFAULT (datetime('now'))
+            )";
+        }
+
+        $pdo = self::connect();
+        foreach ($tables as $sql) {
+            $pdo->exec($sql);
+        }
+
+        /* SQLite-only indexes */
+        if (!$mysql) {
+            $pdo->exec("CREATE INDEX IF NOT EXISTS ix_topics_cat  ON topics(category_id)");
+            $pdo->exec("CREATE INDEX IF NOT EXISTS ix_posts_topic ON posts(topic_id)");
+            $pdo->exec("CREATE INDEX IF NOT EXISTS ix_notif_user  ON notifications(user_id)");
+            $pdo->exec("CREATE INDEX IF NOT EXISTS ix_rate_user   ON rate_events(user_id, event_type, created_at)");
+            $pdo->exec("CREATE INDEX IF NOT EXISTS ix_friends_u   ON friends(user_id)");
+            $pdo->exec("CREATE INDEX IF NOT EXISTS ix_msg_recv    ON messages(receiver_id)");
+        }
+
+        /* ── Column migrations (safe for both SQLite + MySQL upgrades) ── */
+        // Add columns that were introduced in later versions.
+        // Uses IF NOT EXISTS logic compatible with both drivers.
+        if ($mysql) {
+            // MySQL: check information_schema then ALTER TABLE if column missing
+            $dbName = defined('DB_NAME') ? DB_NAME : '';
+            $existingCols = array_column(self::rows(
+                "SELECT COLUMN_NAME FROM information_schema.COLUMNS
+                 WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'users'",
+                [$dbName]
+            ), 'COLUMN_NAME');
+            $mysqlAdd = [
+                'karma'          => "ALTER TABLE users ADD COLUMN karma INT NOT NULL DEFAULT 0",
+                'permissions'    => "ALTER TABLE users ADD COLUMN permissions TEXT NOT NULL DEFAULT '{}'",
+                'friends_hidden' => "ALTER TABLE users ADD COLUMN friends_hidden TINYINT NOT NULL DEFAULT 0",
+                'suspended'      => "ALTER TABLE users ADD COLUMN suspended TINYINT NOT NULL DEFAULT 0",
+                'silenced'       => "ALTER TABLE users ADD COLUMN silenced TINYINT NOT NULL DEFAULT 0",
+                'topic_count'    => "ALTER TABLE users ADD COLUMN topic_count INT NOT NULL DEFAULT 0",
+            ];
+            foreach ($mysqlAdd as $col => $sql) {
+                if (!in_array($col, $existingCols)) {
+                    try { self::run($sql); } catch (\Throwable $e) { /* already exists */ }
+                }
+            }
+        } else {
+            // SQLite: use PRAGMA table_info
+            $cols = array_column(self::rows("PRAGMA table_info(users)"), 'name');
+            $sqliteAdd = [
+                'karma'          => "ALTER TABLE users ADD COLUMN karma INTEGER NOT NULL DEFAULT 0",
+                'permissions'    => "ALTER TABLE users ADD COLUMN permissions TEXT NOT NULL DEFAULT '{}'",
+                'friends_hidden' => "ALTER TABLE users ADD COLUMN friends_hidden INTEGER NOT NULL DEFAULT 0",
+                'suspended'      => "ALTER TABLE users ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0",
+                'silenced'       => "ALTER TABLE users ADD COLUMN silenced INTEGER NOT NULL DEFAULT 0",
+                'topic_count'    => "ALTER TABLE users ADD COLUMN topic_count INTEGER NOT NULL DEFAULT 0",
+            ];
+            foreach ($sqliteAdd as $col => $sql) {
+                if (!in_array($col, $cols)) {
+                    try { self::run($sql); } catch (\Throwable $e) { /* already exists */ }
+                }
+            }
+        }
+
+        /* ── Categories: role permission columns ── */
+        if ($mysql) {
+            $dbName = defined('DB_NAME') ? DB_NAME : '';
+            $catCols = array_column(self::rows(
+                "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME='categories'",
+                [$dbName]
+            ), 'COLUMN_NAME');
+            foreach (['read_role'=>"VARCHAR(20) NOT NULL DEFAULT 'guest'",'post_role'=>"VARCHAR(20) NOT NULL DEFAULT 'member'",'reply_role'=>"VARCHAR(20) NOT NULL DEFAULT 'member'"] as $col=>$def) {
+                if (!in_array($col,$catCols)) { try{self::run("ALTER TABLE categories ADD COLUMN $col $def");}catch(\Throwable $e){} }
+            }
+        } else {
+            $catCols = array_column(self::rows("PRAGMA table_info(categories)"),'name');
+            foreach (['read_role'=>"TEXT NOT NULL DEFAULT 'guest'",'post_role'=>"TEXT NOT NULL DEFAULT 'member'",'reply_role'=>"TEXT NOT NULL DEFAULT 'member'"] as $col=>$def) {
+                if (!in_array($col,$catCols)) { try{self::run("ALTER TABLE categories ADD COLUMN $col $def");}catch(\Throwable $e){} }
+            }
+        }
+
+        /* ── Users: add location column ── */
+        if ($mysql) {
+            $dbName = defined('DB_NAME') ? DB_NAME : '';
+            $uCols = array_column(self::rows(
+                "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME='users'",
+                [$dbName]
+            ), 'COLUMN_NAME');
+            if (!in_array('location', $uCols)) {
+                try { self::run("ALTER TABLE users ADD COLUMN location VARCHAR(100) NOT NULL DEFAULT ''"); }
+                catch (\Throwable $e) {}
+            }
+        } else {
+            $uCols2 = array_column(self::rows("PRAGMA table_info(users)"), 'name');
+            if (!in_array('location', $uCols2)) {
+                try { self::run("ALTER TABLE users ADD COLUMN location TEXT NOT NULL DEFAULT ''"); }
+                catch (\Throwable $e) {}
+            }
+        }
+
+        /* ── Messages: add conversation_id for chat threading ── */
+        // conversation_id = LEAST(sender_id, receiver_id) * 1000000 + GREATEST(...)
+        // We store it as a VARCHAR key for easy lookup
+        if ($mysql) {
+            $dbName = defined('DB_NAME') ? DB_NAME : '';
+            $msgCols = array_column(self::rows(
+                "SELECT COLUMN_NAME FROM information_schema.COLUMNS
+                 WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'messages'", [$dbName]
+            ), 'COLUMN_NAME');
+            if (!in_array('conversation_id', $msgCols)) {
+                try {
+                    self::run("ALTER TABLE messages ADD COLUMN conversation_id VARCHAR(30) NOT NULL DEFAULT ''");
+                    self::run("ALTER TABLE messages ADD INDEX ix_conv (conversation_id)");
+                    // Back-fill existing rows
+                    self::run("UPDATE messages SET conversation_id = CONCAT(LEAST(sender_id,receiver_id),'-',GREATEST(sender_id,receiver_id))");
+                } catch (\Throwable $e) { /* ignore */ }
+            }
+        } else {
+            $msgCols = array_column(self::rows("PRAGMA table_info(messages)"), 'name');
+            if (!in_array('conversation_id', $msgCols)) {
+                try {
+                    self::run("ALTER TABLE messages ADD COLUMN conversation_id TEXT NOT NULL DEFAULT ''");
+                    self::run("UPDATE messages SET conversation_id = MIN(sender_id,receiver_id)||'-'||MAX(sender_id,receiver_id)");
+                } catch (\Throwable $e) { /* ignore */ }
+            }
+        }
+    }
+
+
+    /* ── Helpers ─────────────────────────────────────────────── */
+    /** Cross-driver NOW() */
+    public static function now(): string {
+        return self::isMysql() ? 'NOW()' : "datetime('now')";
+    }
+
+    /** Cross-driver datetime comparison for rate limiting */
+    public static function sinceSeconds(int $seconds): string {
+        if (self::isMysql()) {
+            return "DATE_SUB(NOW(), INTERVAL {$seconds} SECOND)";
+        }
+        return "datetime('now','-{$seconds} seconds')";
+    }
+}
diff --git a/includes/functions.php b/includes/functions.php
new file mode 100644
index 0000000..123c04b
--- /dev/null
+++ b/includes/functions.php
@@ -0,0 +1,608 @@
+<?php
+if (!defined('NEXUS')) exit('Forbidden');
+
+/* ── Output escaping ───────────────────────────────────────── */
+function e(mixed $v): string {
+    return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+}
+
+/* ── URL building ──────────────────────────────────────────── */
+function u(string $path = ''): string {
+    return BASE . '/' . ltrim($path, '/');
+}
+function asset(string $path): string {
+    return BASE . '/public/' . ltrim($path, '/');
+}
+
+/* ── Redirects ─────────────────────────────────────────────── */
+function go(string $path): never {
+    header('Location: ' . u($path));
+    exit;
+}
+
+/* ── Settings ──────────────────────────────────────────────── */
+function cfg(string $key, string $default = ''): string {
+    static $c = null;
+    if ($c === null) {
+        try {
+            $rows = DB::rows('SELECT key, value FROM settings');
+            $c = array_column($rows, 'value', 'key');
+        } catch (\Throwable $e) { $c = []; }
+    }
+    return $c[$key] ?? $default;
+}
+function cfg_set(string $key, string $value): void {
+    DB::upsert('settings', 'key', 'value', $key, $value);
+}
+
+/* ── Active theme CSS ──────────────────────────────────────── */
+function active_theme_css(): string {
+    try {
+        $theme = DB::row("SELECT css FROM themes WHERE is_active=1 LIMIT 1");
+        return $theme ? '<style id="theme-css">' . $theme['css'] . '</style>' : '';
+    } catch (\Throwable $e) { return ''; }
+}
+
+/* ── Auth ──────────────────────────────────────────────────── */
+function start_session(): void {
+    if (session_status() === PHP_SESSION_ACTIVE) return;
+    session_set_cookie_params(['lifetime' => 86400*30,'path'=>'/','httponly'=>true,'samesite'=>'Lax']);
+    session_start();
+}
+function current_user(): ?array {
+    start_session();
+    if (empty($_SESSION['uid'])) return null;
+    $u = DB::row('SELECT * FROM users WHERE id=?', [$_SESSION['uid']]);
+    if (!$u) { unset($_SESSION['uid']); return null; }
+    $now = DB::now();
+    DB::run("UPDATE users SET last_seen=$now WHERE id=?", [$u['id']]);
+    return $u;
+}
+function login_user(int $id): void {
+    start_session();
+    session_regenerate_id(true);
+    $_SESSION['uid'] = $id;
+}
+function logout_user(): void {
+    start_session();
+    session_destroy();
+}
+function must_login(): void {
+    global $USER;
+    if (!$USER) go('auth/login.php?next=' . urlencode($_SERVER['REQUEST_URI']));
+}
+function must_admin(): void {
+    global $USER;
+    must_login();
+    if (!is_admin()) render_403();
+}
+function must_admin_only(): void {
+    global $USER;
+    must_login();
+    if ($USER['role'] !== 'admin') render_403();
+}
+function is_admin(?array $u = null): bool {
+    global $USER;
+    $u = $u ?? $USER;
+    return $u && in_array($u['role'], ['admin','moderator']);
+}
+/* Check a specific permission (stored as JSON in users.permissions) */
+function has_perm(string $perm, ?array $u = null): bool {
+    global $USER;
+    $u = $u ?? $USER;
+    if (!$u) return false;
+    if ($u['role'] === 'admin') return true;
+    $perms = json_decode($u['permissions'] ?? '{}', true) ?: [];
+    return !empty($perms[$perm]);
+}
+
+/* ── CSRF ──────────────────────────────────────────────────── */
+function csrf(): string {
+    start_session();
+    if (empty($_SESSION['csrf'])) $_SESSION['csrf'] = bin2hex(random_bytes(32));
+    return $_SESSION['csrf'];
+}
+function csrf_input(): string {
+    return '<input type="hidden" name="csrf" value="' . e(csrf()) . '">';
+}
+function csrf_ok(): bool {
+    $t = $_POST['csrf'] ?? '';
+    return $t !== '' && hash_equals(csrf(), $t);
+}
+
+/* ── Slugs ─────────────────────────────────────────────────── */
+function make_slug(string $text): string {
+    $s = mb_strtolower(trim($text), 'UTF-8');
+    $s = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $s);
+    $s = preg_replace('/[\s\-]+/', '-', $s);
+    return substr($s, 0, 80) ?: 'post';
+}
+function unique_slug(string $text, string $table, ?int $skip = null): string {
+    $base = make_slug($text); $slug = $base; $n = 1;
+    while (true) {
+        $sql = "SELECT id FROM $table WHERE slug=?"; $p = [$slug];
+        if ($skip) { $sql .= ' AND id!=?'; $p[] = $skip; }
+        if (!DB::row($sql, $p)) break;
+        $slug = $base . '-' . $n++;
+    }
+    return $slug;
+}
+
+/* ── Time ──────────────────────────────────────────────────── */
+function time_ago(string $dt): string {
+    $diff = time() - strtotime($dt);
+    if ($diff <    60) return 'just now';
+    if ($diff <  3600) return floor($diff/60)   . 'm ago';
+    if ($diff < 86400) return floor($diff/3600)  . 'h ago';
+    if ($diff < 604800) return floor($diff/86400) . 'd ago';
+    return date('M j, Y', strtotime($dt));
+}
+
+/* ── Notifications ─────────────────────────────────────────── */
+function unread_count(): int {
+    global $USER;
+    if (!$USER) return 0;
+    return (int) DB::val('SELECT COUNT(*) FROM notifications WHERE user_id=? AND `read`=0', [$USER['id']]);
+}
+function add_notification(int $uid, string $type, array $data): void {
+    global $USER;
+    if ($USER && $uid === (int)$USER['id']) return;
+    DB::insert('INSERT INTO notifications (user_id,type,payload) VALUES (?,?,?)',
+        [$uid, $type, json_encode($data)]);
+}
+
+/* ── Karma ─────────────────────────────────────────────────── */
+function add_karma(int $uid, int $pts = 1): void {
+    DB::run('UPDATE users SET karma=karma+? WHERE id=?', [$pts, $uid]);
+}
+
+/* ── Sidebar categories ────────────────────────────────────── */
+function nav_categories(): array {
+    return DB::rows('SELECT * FROM categories WHERE parent_id IS NULL ORDER BY position, id');
+}
+
+/* ── Forum-wide stats ──────────────────────────────────────── */
+function forum_stats(): array {
+    return [
+        'topics'  => (int) DB::val('SELECT COUNT(*) FROM topics WHERE archived=0'),
+        'posts'   => (int) DB::val('SELECT COUNT(*) FROM posts WHERE deleted=0'),
+        'users'   => (int) DB::val('SELECT COUNT(*) FROM users'),
+        'online'  => (int) DB::val("SELECT COUNT(*) FROM users WHERE last_seen >= ".DB::sinceSeconds(900)),
+        'newest'  => DB::row('SELECT username FROM users ORDER BY joined_at DESC LIMIT 1'),
+    ];
+}
+
+/* ── Error pages ───────────────────────────────────────────── */
+function render_403(): never {
+    http_response_code(403);
+    echo '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>403</title>'
+        .'<link rel="stylesheet" href="'.asset('css/main.css').'"></head>'
+        .'<body class="auth-body"><div class="err-pg"><div class="err-code">403</div>'
+        .'<h1>Access Denied</h1><p>You do not have permission to view this page.</p>'
+        .'<a href="'.u('/').'\" class="btn-primary">← Home</a></div></body></html>';
+    exit;
+}
+function render_404(): never {
+    http_response_code(404);
+    echo '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>404</title>'
+        .'<link rel="stylesheet" href="'.asset('css/main.css').'"></head>'
+        .'<body class="auth-body"><div class="err-pg"><div class="err-code">404</div>'
+        .'<h1>Not Found</h1><p>The page you are looking for does not exist.</p>'
+        .'<a href="'.u('/').'\" class="btn-primary">← Home</a></div></body></html>';
+    exit;
+}
+
+/* ── POST/GET helpers ──────────────────────────────────────── */
+function post(string $k, string $d = ''): string { return trim($_POST[$k] ?? $d); }
+function get(string $k, string $d = ''): string  { return trim($_GET[$k]  ?? $d); }
+
+/* ── JSON response ─────────────────────────────────────────── */
+function json_out(mixed $data, int $code = 200): never {
+    http_response_code($code);
+    header('Content-Type: application/json');
+    echo json_encode($data);
+    exit;
+}
+
+/* ── Security: Input sanitisation ────────────────────────────
+ * ALL user content MUST pass through sanitise() before DB insert.
+ * Strips HTML, PHP, script tags, and HTML entities.
+ */
+function sanitise(string $input): string {
+    // ── 1. Kill PHP execution tags completely ────────────────────────
+    $s = preg_replace('/<\?(?:php|=)?.*?\?>/si', '', $input);
+
+    // ── 2. Multi-round decode to catch all encoding tricks ───────────
+    //      &#106;avascript:, &lt;script&gt;, \u003cscript\u003e etc.
+    for ($i = 0; $i < 4; $i++) {
+        $prev = $s;
+        $s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+        // Resolve JS-style unicode escapes: \u003c → <
+        $s = preg_replace_callback('/\\\\u([0-9a-fA-F]{4})/', function ($m) {
+            return mb_chr(hexdec($m[1]), 'UTF-8') ?? $m[0];
+        }, $s);
+        if ($s === $prev) break;
+    }
+
+    // ── 3. Strip execution-dangerous HTML completely (with content) ──
+    //      Everything inside these tags is removed, not just the tags.
+    $exec = 'script|style|iframe|frame|frameset|object|embed|applet|form';
+    $s = preg_replace('/<(' . $exec . ')\b[^>]*>.*?<\/\1>/si', '', $s);
+    $s = preg_replace('/<(' . $exec . ')\b[^>]*\/?>/si', '', $s);
+
+    // Also strip PHP/server-side tags that survived step 1
+    $s = preg_replace('/<\?.*?\?>/s', '', $s);
+
+    // ── 4. NOW escape all remaining < > & so they display as text ────
+    //      Safe HTML tags like <b>, <i>, <p>, custom tags from users:
+    //      they are NOT executed — they show as literal &lt;b&gt; etc.
+    //      This is the key change: ESCAPE instead of STRIP harmless tags.
+    $s = htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+
+    // ── 5. Remove dangerous URL schemes (defence in depth) ───────────
+    $s = preg_replace('/\b(javascript|vbscript|livescript|mocha)\s*:/i', '[blocked]:', $s);
+
+    // ── 6. Remove ASCII control characters ───────────────────────────
+    //      Keep tab (\x09) and newlines. Remove everything else < \x20.
+    $s = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $s);
+
+    // ── 7. Normalise line endings ─────────────────────────────────────
+    $s = str_replace("\r\n", "\n", $s);
+    $s = str_replace("\r",   "\n", $s);
+
+    return trim($s);
+}
+
+/* ── Math captcha ─────────────────────────────────────────── */
+function captcha_generate(): array {
+    start_session();
+    $a  = random_int(2, 9);
+    $b  = random_int(1, 9);
+    $op = ['+', '-'][random_int(0, 1)];
+    // Ensure subtraction result is always positive and non-zero
+    if ($op === '-') {
+        if ($a <= $b) $b = $a - 1;
+        if ($b < 1)   { $op = '+'; }
+    }
+    $ans = $op === '+' ? $a + $b : $a - $b;
+    $_SESSION['captcha_ans'] = $ans;
+    return ['q' => "$a $op $b = ?"];
+}
+function captcha_verify(string $input): bool {
+    start_session();
+    $ans = $_SESSION['captcha_ans'] ?? null;
+    unset($_SESSION['captcha_ans']);
+    return $ans !== null && intval(trim($input)) === (int)$ans;
+}
+
+/* ── Friend helpers ───────────────────────────────────────── */
+function friend_status(int $userId, int $otherId): string {
+    if ($userId === $otherId) return 'self';
+    try {
+        $r = DB::row(
+            'SELECT status, user_id FROM friends
+             WHERE (user_id=? AND friend_id=?) OR (user_id=? AND friend_id=?)',
+            [$userId, $otherId, $otherId, $userId]
+        );
+        if (!$r) return 'none';
+        if ($r['status'] === 'accepted') return 'friends';
+        if ($r['status'] === 'pending')
+            return ((int)$r['user_id'] === $userId) ? 'pending_sent' : 'pending_received';
+    } catch (Throwable $e) {
+        return 'none'; // Table may not exist on old installs
+    }
+    return 'none';
+}
+
+function friends_list(int $userId): array {
+    try {
+        return DB::rows("
+            SELECT u.* FROM users u
+            JOIN friends f ON (f.user_id=? AND f.friend_id=u.id)
+                           OR (f.friend_id=? AND f.user_id=u.id)
+            WHERE f.status='accepted'
+            ORDER BY u.username
+        ", [$userId, $userId]);
+    } catch (Throwable $e) {
+        return []; // Table may not exist on old installs
+    }
+}
+
+function pending_requests(int $userId): array {
+    try {
+        return DB::rows("
+            SELECT u.*, f.id AS fid FROM users u
+            JOIN friends f ON f.user_id=u.id AND f.friend_id=? AND f.status='pending'
+            ORDER BY f.created_at DESC
+        ", [$userId]);
+    } catch (Throwable $e) {
+        return []; // Table may not exist on old installs
+    }
+}
+
+/* ── Message unread count ─────────────────────────────────── */
+function unread_messages(): int {
+    global $USER;
+    if (!$USER) return 0;
+    try {
+        return (int) DB::val(
+            'SELECT COUNT(*) FROM messages
+             WHERE receiver_id=? AND is_read=0 AND deleted_by_receiver=0',
+            [$USER['id']]
+        );
+    } catch (Throwable $e) {
+        return 0; // Table may not exist on old installs
+    }
+}
+
+/* ── @mention processor ───────────────────────────────────── */
+function process_mentions(string $content, int $postId, string $topicSlug, int $authorId, string $authorName): void {
+    preg_match_all('/@([a-zA-Z0-9_\-]{3,30})/', $content, $m);
+    $mentioned = array_unique($m[1] ?? []);
+    foreach ($mentioned as $uname) {
+        $t = DB::row('SELECT id FROM users WHERE username=?', [$uname]);
+        if ($t && (int)$t['id'] !== $authorId) {
+            add_notification((int)$t['id'], 'mention', [
+                'from'      => $authorName,
+                'topicSlug' => $topicSlug,
+                'postId'    => $postId,
+            ]);
+        }
+    }
+}
+
+/* ── Rate limiting ─────────────────────────────────────────── */
+/**
+ * Check if a user has exceeded their rate limit.
+ * Returns true = allowed, false = rate-limited.
+ * $limit = max posts allowed, $window = seconds window, $type = event type
+ */
+function rate_check(int $userId, string $type = 'post'): array {
+    $enabled = cfg('rate_limit_enabled','0');
+    if ($enabled !== '1') return ['ok'=>true,'wait'=>0];
+
+    // Admins/moderators bypass rate limits
+    $user = DB::row('SELECT role FROM users WHERE id=?', [$userId]);
+    if ($user && in_array($user['role'],['admin','moderator'])) return ['ok'=>true,'wait'=>0];
+
+    $limit  = (int) cfg('rate_limit_count','3');
+    $window = (int) cfg('rate_limit_window','60'); // seconds
+
+    $since = DB::sinceSeconds($window);
+    $count = (int) DB::val(
+        "SELECT COUNT(*) FROM rate_events
+         WHERE user_id=? AND event_type=? AND created_at >= $since",
+        [$userId, $type]
+    );
+
+    if ($count >= $limit) {
+        // Find oldest event in window to calculate wait time
+        $oldest = DB::row(
+            "SELECT created_at FROM rate_events
+             WHERE user_id=? AND event_type=? AND created_at >= $since
+             ORDER BY created_at ASC LIMIT 1",
+            [$userId, $type]
+        );
+        $wait = 0;
+        if ($oldest) {
+            $elapsed = time() - strtotime($oldest['created_at']);
+            $wait    = max(0, $window - $elapsed);
+        }
+        return ['ok'=>false,'wait'=>$wait,'limit'=>$limit,'window'=>$window];
+    }
+    return ['ok'=>true,'wait'=>0];
+}
+
+function rate_record(int $userId, string $type = 'post'): void {
+    $enabled = cfg('rate_limit_enabled','0');
+    if ($enabled !== '1') return;
+    DB::insert('INSERT INTO rate_events (user_id,event_type) VALUES (?,?)', [$userId, $type]);
+    // Prune old events (older than 24h) periodically
+    if (random_int(1,50) === 1) {
+        $cutoff = DB::sinceSeconds(86400);
+        DB::run("DELETE FROM rate_events WHERE created_at < $cutoff");
+    }
+}
+
+/* ── Post/Reply captcha ────────────────────────────────────── */
+function post_captcha_enabled(): bool {
+    return cfg('post_captcha_enabled','0') === '1';
+}
+function post_captcha_verify(string $input): bool {
+    start_session();
+    $key = 'post_captcha_ans';
+    $ans = $_SESSION[$key] ?? null;
+    unset($_SESSION[$key]);
+    return $ans !== null && intval(trim($input)) === (int)$ans;
+}
+function post_captcha_generate(): array {
+    start_session();
+    $a  = random_int(2, 9);
+    $b  = random_int(1, 9);
+    $op = ['+', '-'][random_int(0, 1)];
+    if ($op === '-') {
+        if ($a <= $b) $b = $a - 1;
+        if ($b < 1)   { $op = '+'; }
+    }
+    $ans = $op === '+' ? $a + $b : $a - $b;
+    $_SESSION['post_captcha_ans'] = $ans;
+    return ['q' => "$a $op $b = ?"];
+}
+
+/* ── Media embed processor ─────────────────────────────────────
+ * Converts bare URLs in post content into embeds.
+ * Called server-side so embeds render even without JS.
+ * Each URL on its own line (possibly wrapped in <p>) gets replaced.
+ */
+function process_embeds(string $html): string {
+    // Process each <p>...</p> block that contains a bare URL.
+    // We do this line by line to avoid catastrophic regex failures.
+    return preg_replace_callback(
+        '/<p>\s*(https?:\/\/[^\s<>"\']+)\s*<\/p>/i',
+        function ($m) {
+            $url = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8');
+            $em  = try_embed($url);
+            return $em !== null ? $em : $m[0];
+        },
+        $html
+    );
+}
+
+function try_embed(string $url): ?string {
+    // YouTube (full URL)
+    if (preg_match('~youtube\.com/watch\?.*v=([a-zA-Z0-9_\-]{11})~', $url, $m)) {
+        return yt_embed($m[1]);
+    }
+    // YouTube short URL
+    if (preg_match('~youtu\.be/([a-zA-Z0-9_\-]{11})~', $url, $m)) {
+        return yt_embed($m[1]);
+    }
+    // YouTube Shorts
+    if (preg_match('~youtube\.com/shorts/([a-zA-Z0-9_\-]{11})~', $url, $m)) {
+        return yt_embed($m[1], true);
+    }
+    // YouTube Music
+    if (preg_match('~music\.youtube\.com/watch\?.*v=([a-zA-Z0-9_\-]{11})~', $url, $m)) {
+        return yt_embed($m[1]);
+    }
+    // Vimeo
+    if (preg_match('~vimeo\.com/(\d{5,12})~', $url, $m)) {
+        return embed_iframe('https://player.vimeo.com/video/' . $m[1] . '?dnt=1', 'Vimeo');
+    }
+    // Twitch VOD
+    if (preg_match('~twitch\.tv/videos/(\d+)~', $url, $m)) {
+        $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
+        return embed_iframe('https://player.twitch.tv/?video=v' . $m[1] . '&parent=' . urlencode($host) . '&autoplay=false', 'Twitch VOD');
+    }
+    // Twitch channel
+    if (preg_match('~twitch\.tv/([a-zA-Z0-9_]{4,25})(?:\?|$|/)~', $url, $m)) {
+        $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
+        return embed_iframe('https://player.twitch.tv/?channel=' . $m[1] . '&parent=' . urlencode($host) . '&autoplay=false', 'Twitch');
+    }
+    // Dailymotion
+    if (preg_match('~dailymotion\.com/video/([a-zA-Z0-9]+)~', $url, $m)) {
+        return embed_iframe('https://www.dailymotion.com/embed/video/' . $m[1], 'Dailymotion');
+    }
+    // Streamable
+    if (preg_match('~streamable\.com/([a-zA-Z0-9]+)~', $url, $m)) {
+        return embed_iframe('https://streamable.com/e/' . $m[1], 'Streamable');
+    }
+    // Rumble
+    if (preg_match('~rumble\.com/(?:embed/)?([a-zA-Z0-9\-]+)(?:\.html)?~', $url, $m)) {
+        return embed_iframe('https://rumble.com/embed/' . $m[1] . '/', 'Rumble');
+    }
+    // Spotify
+    if (preg_match('~open\.spotify\.com/(track|album|playlist|episode|artist)/([a-zA-Z0-9]+)~', $url, $m)) {
+        $h = ($m[1] === 'track' || $m[1] === 'episode') ? '152' : '352';
+        return '<div class="embed-spotify"><iframe src="https://open.spotify.com/embed/' . $m[1] . '/' . $m[2]
+            . '" width="100%" height="' . $h . '" frameborder="0" allow="autoplay;clipboard-write;encrypted-media;fullscreen" loading="lazy"></iframe></div>';
+    }
+    // SoundCloud
+    if (preg_match('~soundcloud\.com/[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_]+~', $url)) {
+        return '<div class="embed-sc"><iframe width="100%" height="166" scrolling="no" frameborder="no" allow="autoplay"'
+            . ' src="https://w.soundcloud.com/player/?url=' . urlencode($url) . '&color=%233b82f6&auto_play=false"></iframe></div>';
+    }
+    // Loom
+    if (preg_match('~loom\.com/share/([a-zA-Z0-9]+)~', $url, $m)) {
+        return embed_iframe('https://www.loom.com/embed/' . $m[1], 'Loom');
+    }
+    // CodePen
+    if (preg_match('~codepen\.io/([a-zA-Z0-9\-_]+)/pen/([a-zA-Z0-9]+)~', $url, $m)) {
+        return embed_iframe_tall('https://codepen.io/' . $m[1] . '/embed/' . $m[2] . '?default-tab=result', 'CodePen', 420);
+    }
+    // JSFiddle
+    if (preg_match('~jsfiddle\.net/([a-zA-Z0-9/]+)~', $url, $m)) {
+        return embed_iframe_tall('https://jsfiddle.net/' . rtrim($m[1], '/') . '/embedded/result', 'JSFiddle', 380);
+    }
+    // Twitter / X
+    if (preg_match('~(?:twitter|x)\.com/[a-zA-Z0-9_]+/status/(\d+)~', $url, $m)) {
+        $safe = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
+        return '<div class="embed-tweet" data-tweet-id="' . $m[1] . '">'
+            . '<a href="' . $safe . '" target="_blank" rel="noopener" class="tweet-fallback">🐦 View on Twitter/X →</a></div>';
+    }
+    // TED Talks
+    if (preg_match('~ted\.com/talks/([a-zA-Z0-9_]+)~', $url, $m)) {
+        return embed_iframe('https://embed.ted.com/talks/' . $m[1], 'TED Talk');
+    }
+    // Bandcamp track
+    if (preg_match('~([a-zA-Z0-9\-]+)\.bandcamp\.com/track/([a-zA-Z0-9\-]+)~', $url, $m)) {
+        return '<div class="embed-spotify"><iframe style="border:0;width:100%;height:120px"'
+            . ' src="https://bandcamp.com/EmbeddedPlayer/track=' . urlencode($m[2]) . '/size=large/bgcol=ffffff/linkcol=0687f5/tracklist=false/artwork=small/" seamless></iframe></div>';
+    }
+    // No match
+    return null;
+}
+
+
+function yt_embed(string $id, bool $short = false): string {
+    $pad = $short ? 'padding-bottom:177.78%;max-width:360px' : 'padding-bottom:56.25%';
+    return '<div class="embed-wrap" style="'.$pad.'"><iframe class="embed-yt" src="https://www.youtube-nocookie.com/embed/'.htmlspecialchars($id).'?rel=0&amp;modestbranding=1" allowfullscreen loading="lazy" title="YouTube video"></iframe></div>';
+}
+function embed_iframe(string $src, string $label = ''): string {
+    return '<div class="embed-wrap"><iframe class="embed-yt" src="'.htmlspecialchars($src).'" allowfullscreen loading="lazy" title="'.htmlspecialchars($label).'"></iframe></div>';
+}
+function embed_iframe_tall(string $src, string $label = '', int $height = 400): string {
+    return '<div class="embed-wrap" style="padding-bottom:0;height:'.$height.'px"><iframe class="embed-yt" src="'.htmlspecialchars($src).'" allowfullscreen loading="lazy" title="'.htmlspecialchars($label).'"></iframe></div>';
+}
+
+/* ── Karma tier system ─────────────────────────────────────── */
+/* ── Category permission helpers ──────────────────────── */
+// Roles in ascending order: guest < member < moderator < admin
+function role_level(string $role): int {
+    return match($role) {
+        'admin'     => 30,
+        'moderator' => 20,
+        'member'    => 10,
+        default     => 0,   // guest / not logged in
+    };
+}
+
+function user_role_level(?array $u = null): int {
+    global $USER;
+    $u = $u ?? $USER;
+    if (!$u) return 0;
+    return role_level($u['role'] ?? 'member');
+}
+
+function can_read_category(array $cat, ?array $u = null): bool {
+    $required = role_level($cat['read_role'] ?? 'guest');
+    return user_role_level($u) >= $required;
+}
+
+function can_post_topic(array $cat, ?array $u = null): bool {
+    $required = role_level($cat['post_role'] ?? 'member');
+    return user_role_level($u) >= $required;
+}
+
+function can_reply_topic(array $cat, ?array $u = null): bool {
+    $required = role_level($cat['reply_role'] ?? 'member');
+    return user_role_level($u) >= $required;
+}
+
+function karma_tier(int $karma): array {
+    $tiers = [
+        ['name'=>'Newcomer',     'min'=>0,    'max'=>9,    'icon'=>'🌱', 'color'=>'#94a3b8', 'next'=>10],
+        ['name'=>'Member',       'min'=>10,   'max'=>49,   'icon'=>'💬', 'color'=>'#64748b', 'next'=>50],
+        ['name'=>'Regular',      'min'=>50,   'max'=>99,   'icon'=>'⭐', 'color'=>'#f59e0b', 'next'=>100],
+        ['name'=>'Contributor',  'min'=>100,  'max'=>249,  'icon'=>'🌟', 'color'=>'#f97316', 'next'=>250],
+        ['name'=>'Veteran',      'min'=>250,  'max'=>499,  'icon'=>'🔥', 'color'=>'#ef4444', 'next'=>500],
+        ['name'=>'Expert',       'min'=>500,  'max'=>999,  'icon'=>'💎', 'color'=>'#8b5cf6', 'next'=>1000],
+        ['name'=>'Elite',        'min'=>1000, 'max'=>2499, 'icon'=>'👑', 'color'=>'#7c3aed', 'next'=>2500],
+        ['name'=>'Legend',       'min'=>2500, 'max'=>PHP_INT_MAX, 'icon'=>'🏆', 'color'=>'#6d28d9', 'next'=>null],
+    ];
+    $current = $tiers[0];
+    foreach ($tiers as $tier) {
+        if ($karma >= $tier['min']) $current = $tier;
+        else break;
+    }
+    // Progress to next tier
+    $progress = 0;
+    if ($current['next'] !== null) {
+        $range    = $current['next'] - $current['min'];
+        $earned   = $karma - $current['min'];
+        $progress = $range > 0 ? min(100, (int)round($earned / $range * 100)) : 100;
+    } else {
+        $progress = 100;
+    }
+    return array_merge($current, ['karma' => $karma, 'progress' => $progress]);
+}
diff --git a/includes/markdown.php b/includes/markdown.php
new file mode 100644
index 0000000..ff4fed3
--- /dev/null
+++ b/includes/markdown.php
@@ -0,0 +1,347 @@
+<?php
+if (!defined('NEXUS')) exit('Forbidden');
+
+/**
+ * Nexus Forum — Markdown renderer (Discourse/Flarum-compatible)
+ *
+ * Input has already been sanitised by sanitise() which:
+ *  - Strips dangerous tags (script, iframe, etc.)
+ *  - htmlspecialchars() remaining content (< > & become entities)
+ *
+ * Supported syntax:
+ *   ```lang  …  ```   fenced code block with syntax highlighting
+ *   ```      …  ```   fenced code block, no language
+ *   `inline`          inline code span
+ *   > text            blockquote (one or more lines)
+ *   **bold**          bold
+ *   *italic*          italic
+ *   ~~strike~~        strikethrough
+ *   # – ######        headings
+ *   - item / * item   bullet list
+ *   1. item           ordered list
+ *   [text](url)       link
+ *   ![alt](url)       image
+ *   @username         mention
+ *   ---               horizontal rule
+ *   https://…         auto-embed videos / auto-link
+ */
+
+// ─────────────────────────────────────────────────────────────────
+// Placeholder markers (STX/ETX can't appear in user content)
+// ─────────────────────────────────────────────────────────────────
+define('_MK_FP', "\x02FENCE");
+define('_MK_FS', "FNCE\x03");
+define('_MK_IP', "\x02INLIN");
+define('_MK_IS', "INLN\x03");
+define('_MK_BQ', "\x02BQUOT");
+define('_MK_BS', "BQUT\x03");
+
+function render_post(string $raw): string
+{
+    // ── Normalise ────────────────────────────────────────────────
+    $s = str_replace(["\r\n", "\r"], "\n", $raw);
+
+    $fences  = [];
+    $quotes  = [];
+    $inlines = [];
+
+    // ── Safe URL check ───────────────────────────────────────────
+    $safeUrl = static fn(string $u): bool =>
+        str_starts_with(strtolower(trim($u)), '/') ||
+        (bool) preg_match('#^https?://#i', trim($u));
+
+    // ── Copy button SVG ──────────────────────────────────────────
+    $copyBtn = '<button class="cb-copy" onclick="cbCopy(this)" '
+             . 'title="Copy" aria-label="Copy code">'
+             . '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
+             . 'stroke-linecap="round" stroke-linejoin="round" width="13" height="13">'
+             . '<rect x="9" y="9" width="13" height="13" rx="2"/>'
+             . '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'
+             . '</svg><span>Copy</span></button>';
+
+    // ════════════════════════════════════════════════════════════
+    // PASS 1 — Extract code fences (line-by-line state machine)
+    //
+    // Runs BEFORE everything else so code content is never touched
+    // by any other processor. Handles blank lines, indentation,
+    // special characters without any regex backtracking.
+    // ════════════════════════════════════════════════════════════
+    {
+        $in      = false;
+        $lang    = '';
+        $type    = '';
+        $buf     = [];
+        $newLines = [];
+
+        foreach (explode("\n", $s) as $line) {
+            if (!$in) {
+                // Triple-backtick opener: ```lang  or  ```
+                if (preg_match('/^```([ \t]*\w*)[ \t]*$/', $line, $m)) {
+                    $in = true; $type = 'triple'; $lang = trim($m[1]); $buf = [];
+                // Lone backtick on its own line
+                } elseif (preg_match('/^`[ \t]*$/', $line)) {
+                    $in = true; $type = 'single'; $lang = ''; $buf = [];
+                } else {
+                    $newLines[] = $line;
+                }
+            } else {
+                $close = ($type === 'triple' && preg_match('/^```[ \t]*$/', $line))
+                      || ($type === 'single' && preg_match('/^`[ \t]*$/', $line));
+                if ($close) {
+                    // Build code block HTML
+                    // Content is already entity-encoded by sanitise() — don't double-encode
+                    $code = implode("\n", $buf);
+                    $l    = strtolower(trim($lang));
+                    if ($l !== '') {
+                        $lb  = htmlspecialchars($l, ENT_QUOTES, 'UTF-8');
+                        $hdr = '<div class="cb-header">'
+                             . '<span class="cb-lang">' . $lb . '</span>' . $copyBtn
+                             . '</div>';
+                        $blk = '<pre class="code-block" data-lang="' . $lb . '">'
+                             . '<code class="language-' . $lb . '">' . $code . '</code></pre>';
+                    } else {
+                        $hdr = '<div class="cb-header cb-header-nolang">' . $copyBtn . '</div>';
+                        $blk = '<pre class="code-block"><code>' . $code . '</code></pre>';
+                    }
+                    $idx      = count($fences);
+                    $fences[] = '<div class="code-block-wrap">' . $hdr . $blk . '</div>';
+                    $newLines[] = _MK_FP . $idx . _MK_FS;
+                    $in = false; $buf = [];
+                } else {
+                    $buf[] = $line;
+                }
+            }
+        }
+        // Unclosed fence — render what was collected
+        if ($in && $buf) {
+            $code = implode("\n", $buf);
+            $hdr  = '<div class="cb-header cb-header-nolang">' . $copyBtn . '</div>';
+            $blk  = '<pre class="code-block"><code>' . $code . '</code></pre>';
+            $idx  = count($fences);
+            $fences[] = '<div class="code-block-wrap">' . $hdr . $blk . '</div>';
+            $newLines[] = _MK_FP . $idx . _MK_FS;
+        }
+        $s = implode("\n", $newLines);
+    }
+
+    // ════════════════════════════════════════════════════════════
+    // PASS 2 — Extract blockquotes (line-by-line, same approach)
+    //
+    // Consecutive "> " lines form one blockquote block.
+    // Supports nested content: bold, italic, inline code, links.
+    // Matches both raw > and entity-encoded &gt; from sanitise().
+    // ════════════════════════════════════════════════════════════
+    {
+        $bqBuf  = [];
+        $bqOut  = [];
+
+        $flushBq = function () use (&$bqBuf, &$bqOut, &$quotes, $copyBtn): void {
+            if (!$bqBuf) return;
+            $inner = implode("\n", $bqBuf);
+            // Let inline markdown run inside blockquote
+            $inner = preg_replace('/\*\*\*(.+?)\*\*\*/s', '<strong><em>$1</em></strong>', $inner);
+            $inner = preg_replace('/\*\*(.+?)\*\*/s',     '<strong>$1</strong>',          $inner);
+            $inner = preg_replace('/\*([^\*\n]+)\*/',      '<em>$1</em>',                 $inner);
+            $inner = preg_replace('/~~(.+?)~~/s',          '<del>$1</del>',               $inner);
+            // Wrap each line in <p> if multiple lines, otherwise just the text
+            $bqLines = array_filter(explode("\n", $inner), fn($l) => trim($l) !== '');
+            if (count($bqLines) > 1) {
+                $inner = implode('', array_map(fn($l) => '<p>' . trim($l) . '</p>', $bqLines));
+            } else {
+                $inner = trim($inner);
+            }
+            $idx     = count($quotes);
+            $quotes[] = '<blockquote class="post-quote">' . $inner . '</blockquote>';
+            $bqOut[] = _MK_BQ . $idx . _MK_BS;
+            $bqBuf   = [];
+        };
+
+        foreach (explode("\n", $s) as $line) {
+            if (preg_match('/^(?:&gt;|>) ?(.*)$/', $line, $m)) {
+                $bqBuf[] = $m[1];  // already entity-encoded
+            } else {
+                $flushBq();
+                $bqOut[] = $line;
+            }
+        }
+        $flushBq();
+        $s = implode("\n", $bqOut);
+    }
+
+    // ════════════════════════════════════════════════════════════
+    // PASS 3 — Inline code spans
+    // ════════════════════════════════════════════════════════════
+    $s = preg_replace_callback(
+        '/`([^`\n]+)`/',
+        static function (array $m) use (&$inlines): string {
+            $idx = count($inlines);
+            // Content already entity-encoded by sanitise()
+            $inlines[] = '<code class="inline-code">' . $m[1] . '</code>';
+            return _MK_IP . $idx . _MK_IS;
+        },
+        $s
+    );
+
+    // ════════════════════════════════════════════════════════════
+    // PASS 4 — Block-level markdown
+    // ════════════════════════════════════════════════════════════
+
+    // Headings
+    $s = preg_replace('/^#{6} (.+)$/m', '<h6>$1</h6>', $s);
+    $s = preg_replace('/^#{5} (.+)$/m', '<h5>$1</h5>', $s);
+    $s = preg_replace('/^#{4} (.+)$/m', '<h4>$1</h4>', $s);
+    $s = preg_replace('/^#{3} (.+)$/m', '<h3>$1</h3>', $s);
+    $s = preg_replace('/^#{2} (.+)$/m', '<h2>$1</h2>', $s);
+    $s = preg_replace('/^# (.+)$/m',   '<h1>$1</h1>', $s);
+
+    // Horizontal rules
+    $s = preg_replace('/^(-{3,}|\*{3,}|_{3,})$/m', '<hr>', $s);
+
+    // Lists — bullet
+    $s = preg_replace('/^[ \t]*[*\-+] (.+)$/m',       '<li>$1</li>',  $s);
+    $s = preg_replace('/((?:<li>.*<\/li>\n?)+)/',      '<ul>$1</ul>',  $s);
+    $s = preg_replace('/<\/ul>\s*<ul>/',                '',             $s);
+    $s = preg_replace_callback('/<ul>(.*?)<\/ul>/s',
+         static fn($m) => '<ul>' . str_replace("\n", '', $m[1]) . '</ul>', $s);
+
+    // Lists — ordered
+    $s = preg_replace('/^[ \t]*\d+\. (.+)$/m',        '<oli>$1</oli>',$s);
+    $s = preg_replace('/((?:<oli>.*<\/oli>\n?)+)/',    '<ol>$1</ol>',  $s);
+    $s = preg_replace('/<\/ol>\s*<ol>/',                '',             $s);
+    $s = str_replace(['<oli>', '</oli>'], ['<li>', '</li>'], $s);
+    $s = preg_replace_callback('/<ol>(.*?)<\/ol>/s',
+         static fn($m) => '<ol>' . str_replace("\n", '', $m[1]) . '</ol>', $s);
+
+    // Tables
+    $s = preg_replace_callback('/\|(.+)\|\n\|[-| :]+\|\n((?:\|.+\|\n?)+)/',
+        static function (array $m): string {
+            $ths = implode('', array_map(
+                static fn($c) => '<th>' . trim($c) . '</th>',
+                array_filter(explode('|', $m[1]), static fn($c) => trim($c) !== '')
+            ));
+            $trs = implode('', array_map(static function (string $row): string {
+                $cells = array_filter(explode('|', $row), static fn($c) => trim($c) !== '');
+                return '<tr>' . implode('', array_map(static fn($c) => '<td>' . trim($c) . '</td>', $cells)) . '</tr>';
+            }, array_filter(explode("\n", trim($m[2])))));
+            return '<table><thead><tr>' . $ths . '</tr></thead><tbody>' . $trs . '</tbody></table>';
+        }, $s);
+
+    // ════════════════════════════════════════════════════════════
+    // PASS 5 — Inline markdown
+    // ════════════════════════════════════════════════════════════
+
+    $s = preg_replace('/\*\*\*(.+?)\*\*\*/s', '<strong><em>$1</em></strong>', $s);
+    $s = preg_replace('/\*\*(.+?)\*\*/s',     '<strong>$1</strong>',          $s);
+    $s = preg_replace('/\*([^\*\n]+)\*/',      '<em>$1</em>',                 $s);
+    $s = preg_replace('/___(.+?)___/s',        '<strong><em>$1</em></strong>', $s);
+    $s = preg_replace('/__(.+?)__/s',          '<strong>$1</strong>',          $s);
+    $s = preg_replace('/_([^_\n]+)_/',         '<em>$1</em>',                 $s);
+    $s = preg_replace('/~~(.+?)~~/s',          '<del>$1</del>',               $s);
+
+    // Images
+    $s = preg_replace_callback('/!\[([^\]]*)\]\(([^)]+)\)/',
+        static function (array $m) use ($safeUrl): string {
+            $src = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8');
+            $alt = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8');
+            if (!$safeUrl($src)) return $m[0];
+            return '<img src="' . htmlspecialchars($src, ENT_QUOTES, 'UTF-8')
+                 . '" alt="'   . htmlspecialchars($alt, ENT_QUOTES, 'UTF-8')
+                 . '" loading="lazy" class="post-img" onclick="lightbox(this)">';
+        }, $s);
+
+    // Links
+    $s = preg_replace_callback('/\[([^\]]+)\]\(([^)]+)\)/',
+        static function (array $m) use ($safeUrl): string {
+            $url = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8');
+            $txt = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8');
+            if (!$safeUrl($url)) return htmlspecialchars($txt, ENT_QUOTES, 'UTF-8');
+            return '<a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8')
+                 . '" target="_blank" rel="noopener noreferrer nofollow">'
+                 . htmlspecialchars($txt, ENT_QUOTES, 'UTF-8') . '</a>';
+        }, $s);
+
+    // @mentions
+    $base = BASE;
+    $s = preg_replace_callback('/@([a-zA-Z0-9_\-]{3,30})/',
+        static function (array $m) use ($base): string {
+            $u = htmlspecialchars($m[1], ENT_QUOTES, 'UTF-8');
+            return '<a href="' . $base . '/users/profile.php?u=' . $u
+                 . '" class="mention-tag">@' . $u . '</a>';
+        }, $s);
+
+    // ════════════════════════════════════════════════════════════
+    // PASS 6 — URL auto-embed + auto-link
+    //
+    // EVERY bare URL (on its own line OR inline) is attempted as an embed.
+    // Entity-encoded URLs from sanitise() are decoded before matching.
+    // Multiple video links in one post all embed.
+    // ════════════════════════════════════════════════════════════
+
+    // Decode entity-encoded URLs so patterns match (sanitise turns & → &amp;)
+    // We match, decode, try embed or link, then re-encode for output.
+    $s = preg_replace_callback(
+        '#(^|[ \t]|<p>)(https?://[^\s<>"\'&]+(?:&amp;[^\s<>"\'&]*)*)#m',
+        static function (array $m): string {
+            $pre     = $m[1];
+            $rawUrl  = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8');
+            $safeEnc = htmlspecialchars($rawUrl, ENT_QUOTES, 'UTF-8');
+
+            // Try embed first
+            $embed = try_embed($rawUrl);
+            if ($embed !== null) {
+                // Wrap standalone embeds cleanly
+                return $pre . $embed;
+            }
+
+            // Otherwise link
+            return $pre . '<a href="' . $safeEnc . '" target="_blank" '
+                 . 'rel="noopener noreferrer nofollow">' . $safeEnc . '</a>';
+        },
+        $s
+    );
+
+    // ════════════════════════════════════════════════════════════
+    // PASS 7 — Paragraph wrapping
+    // ════════════════════════════════════════════════════════════
+    $lines   = explode("\n", $s);
+    $out     = [];
+    $para    = '';
+
+    $fpQ  = preg_quote(_MK_FP, '/');
+    $fsQ  = preg_quote(_MK_FS, '/');
+    $bpQ  = preg_quote(_MK_BQ, '/');
+    $bsQ  = preg_quote(_MK_BS, '/');
+
+    $blockRe = '/^(<(h[1-6]|ul|ol|blockquote|pre|table|hr|img|div|figure|p)|'
+             . $fpQ . '\d+' . $fsQ . '|'
+             . $bpQ . '\d+' . $bsQ . ')/';
+
+    $flush = static function () use (&$para, &$out): void {
+        $t = trim($para);
+        if ($t !== '') $out[] = '<p>' . $t . '</p>';
+        $para = '';
+    };
+
+    foreach ($lines as $line) {
+        $t = trim($line);
+        if ($t === '') {
+            $flush();
+        } elseif (preg_match($blockRe, $t)) {
+            $flush();
+            $out[] = $line;
+        } else {
+            $para .= ($para !== '' ? ' ' : '') . $line;
+        }
+    }
+    $flush();
+    $s = implode("\n", $out);
+
+    // ════════════════════════════════════════════════════════════
+    // PASS 8 — Restore all placeholders
+    // ════════════════════════════════════════════════════════════
+    foreach ($fences  as $i => $html) $s = str_replace(_MK_FP . $i . _MK_FS, $html, $s);
+    foreach ($quotes  as $i => $html) $s = str_replace(_MK_BQ . $i . _MK_BS, $html, $s);
+    foreach ($inlines as $i => $html) $s = str_replace(_MK_IP . $i . _MK_IS, $html, $s);
+
+    return process_embeds($s);
+}
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..c47acc2
--- /dev/null
+++ b/index.php
@@ -0,0 +1,107 @@
+<?php
+require_once __DIR__ . '/includes/bootstrap.php';
+
+$PAGE_TITLE = cfg('site_name', 'Nexus Forum');
+
+$cats = DB::rows("
+    SELECT c.*,
+      (SELECT t.title FROM topics t WHERE t.category_id=c.id ORDER BY t.last_post_at DESC LIMIT 1) AS last_title,
+      (SELECT t.slug  FROM topics t WHERE t.category_id=c.id ORDER BY t.last_post_at DESC LIMIT 1) AS last_slug
+    FROM categories c WHERE c.parent_id IS NULL ORDER BY c.position, c.id
+");
+
+$recent = DB::rows("
+    SELECT t.*, u.username, u.avatar, c.name AS cat_name, c.slug AS cat_slug, c.color AS cat_color
+    FROM topics t
+    JOIN users u ON u.id = t.user_id
+    JOIN categories c ON c.id = t.category_id
+    WHERE t.archived = 0
+    ORDER BY t.last_post_at DESC LIMIT 15
+");
+
+$stats = [
+    'topics' => (int) DB::val('SELECT COUNT(*) FROM topics'),
+    'posts'  => (int) DB::val('SELECT COUNT(*) FROM posts WHERE deleted=0'),
+    'users'  => (int) DB::val('SELECT COUNT(*) FROM users'),
+    'newest' => DB::row('SELECT username FROM users ORDER BY joined_at DESC LIMIT 1'),
+];
+
+include __DIR__ . '/views/partials/layout.php';
+?>
+
+<div class="home-hero">
+  <h1><?= e(cfg('site_name','Nexus Forum')) ?></h1>
+  <p><?= e(cfg('site_desc','A community for discussion.')) ?></p>
+  <?php if (!$USER): ?>
+    <div class="hero-btns">
+      <a href="<?= u('auth/register.php') ?>" class="btn-primary btn-lg">Join the Community</a>
+      <a href="<?= u('auth/login.php') ?>" class="btn-ghost btn-lg">Log In</a>
+    </div>
+  <?php else: ?>
+    <a href="<?= u('forum/new-topic.php') ?>" class="btn-primary btn-lg">+ New Topic</a>
+  <?php endif; ?>
+</div>
+
+<div class="stats-row">
+  <div class="stat"><strong><?= number_format($stats['topics']) ?></strong><span>Topics</span></div>
+  <div class="stat-div"></div>
+  <div class="stat"><strong><?= number_format($stats['posts']) ?></strong><span>Posts</span></div>
+  <div class="stat-div"></div>
+  <div class="stat"><strong><?= number_format($stats['users']) ?></strong><span>Members</span></div>
+  <?php if ($stats['newest']): ?>
+    <div class="stat-div"></div>
+    <div class="stat"><span>Newest: <a href="<?= u('users/profile.php?u=' . urlencode($stats['newest']['username'])) ?>">@<?= e($stats['newest']['username']) ?></a></span></div>
+  <?php endif; ?>
+</div>
+
+<div class="home-grid">
+  <section>
+    <h2 class="sec-title">Categories</h2>
+    <?php foreach ($cats as $c): ?>
+      <a href="<?= u('forum/category.php?slug=' . urlencode($c['slug'])) ?>" class="cat-card">
+        <div class="cat-stripe" style="background:<?= e($c['color']) ?>"></div>
+        <div class="cat-icon" style="color:<?= e($c['color']) ?>"><?= e($c['icon']) ?></div>
+        <div class="cat-body">
+          <div class="cat-name"><?= e($c['name']) ?></div>
+          <div class="cat-desc"><?= e($c['description']) ?></div>
+          <div class="cat-stats"><?= $c['topic_count'] ?> topics · <?= $c['post_count'] ?> posts</div>
+        </div>
+        <div class="cat-chevron">›</div>
+      </a>
+    <?php endforeach; ?>
+    <?php if (empty($cats)): ?>
+      <p class="empty-msg">No categories yet.</p>
+    <?php endif; ?>
+  </section>
+
+  <section>
+    <h2 class="sec-title">Latest Activity</h2>
+    <?php foreach ($recent as $t): ?>
+      <div class="topic-row">
+        <?php if ($t['avatar']): ?>
+          <img src="<?= e($t['avatar']) ?>" class="av-sm" alt="">
+        <?php else: ?>
+          <span class="av-sm av-init"><?= strtoupper($t['username'][0]) ?></span>
+        <?php endif; ?>
+        <div class="tr-body">
+          <a href="<?= u('forum/topic.php?slug=' . urlencode($t['slug'])) ?>" class="tr-title">
+            <?= e($t['title']) ?>
+          </a>
+          <div class="tr-meta">
+            <a href="<?= u('forum/category.php?slug=' . urlencode($t['cat_slug'])) ?>" class="cat-tag" style="--cc:<?= e($t['cat_color']) ?>"><?= e($t['cat_name']) ?></a>
+            by <a href="<?= u('users/profile.php?u=' . urlencode($t['username'])) ?>">@<?= e($t['username']) ?></a>
+            <span class="ago" data-ts="<?= e($t['last_post_at']) ?>"></span>
+          </div>
+        </div>
+        <div class="tr-counts">
+          <span>💬 <?= $t['reply_count'] ?></span>
+        </div>
+      </div>
+    <?php endforeach; ?>
+    <?php if (empty($recent)): ?>
+      <p class="empty-msg">No topics yet. <a href="<?= u('forum/new-topic.php') ?>">Start one!</a></p>
+    <?php endif; ?>
+  </section>
+</div>
+
+<?php include __DIR__ . '/views/partials/layout_end.php'; ?>
diff --git a/install/index.php b/install/index.php
new file mode 100644
index 0000000..052e48a
--- /dev/null
+++ b/install/index.php
@@ -0,0 +1,632 @@
+<?php
+/**
+ * Nexus Forum — Web Installer
+ * Supports: SQLite3, MySQL, MariaDB
+ */
+
+/* ── Bootstrap (standalone — does not use bootstrap.php) ─── */
+define('NEXUS', true);
+define('ROOT',  dirname(__DIR__));
+define('DATA',  ROOT . '/data');
+define('UPLOADS', ROOT . '/public/uploads');
+
+// Detect web root offset (so the forum works at /forum/, /app/forum/, etc.)
+$_dr = rtrim(str_replace('\\', '/', $_SERVER['DOCUMENT_ROOT'] ?? ''), '/');
+$_rp = str_replace('\\', '/', ROOT);
+$_b  = str_replace($_dr, '', $_rp);
+$_b  = '/' . trim($_b, '/');
+define('BASE', $_b === '/' ? '' : $_b);
+unset($_dr, $_rp, $_b);
+
+// Already installed → redirect
+if (file_exists(DATA . '/installed.lock')) {
+    header('Location: ' . BASE . '/');
+    exit;
+}
+
+// Load DB class only (no bootstrap, no session)
+require_once ROOT . '/includes/db.php';
+
+/* ── State ──────────────────────────────────────────────── */
+$step      = (int)($_GET['step'] ?? 1);
+$errs      = [];
+$info      = [];
+$selDb     = $_POST['db_type'] ?? 'sqlite';
+
+// Check which PDO drivers are available
+$hasSQLite = extension_loaded('pdo_sqlite')  || in_array('sqlite', PDO::getAvailableDrivers());
+$hasMySQL  = extension_loaded('pdo_mysql')   || in_array('mysql',  PDO::getAvailableDrivers());
+
+/* ── Requirements ───────────────────────────────────────── */
+$reqs = [
+    'PHP 8.0+'                => version_compare(PHP_VERSION, '8.0', '>='),
+    'PDO extension'           => extension_loaded('PDO'),
+    'Cryptographic random'    => function_exists('random_int'),
+    'JSON support'            => function_exists('json_encode'),
+    'SQLite3 or MySQL driver' => $hasSQLite || $hasMySQL,
+    'data/ directory writable'=> !is_dir(DATA) ? is_writable(ROOT) : is_writable(DATA),
+];
+$allOk = !in_array(false, $reqs);
+
+/* ── Step 2: process form ───────────────────────────────── */
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['install_submit'])) {
+
+    // Re-read db choice from POST
+    $selDb = $_POST['db_type'] ?? 'sqlite';
+
+    // Validate common fields
+    $siteName   = trim($_POST['site_name']   ?? '');
+    $siteDesc   = trim($_POST['site_desc']   ?? '');
+    $adminUser  = trim($_POST['admin_user']  ?? '');
+    $adminEmail = trim($_POST['admin_email'] ?? '');
+    $adminPass  = $_POST['admin_pass']  ?? '';
+    $adminPass2 = $_POST['admin_pass2'] ?? '';
+
+    if (!$siteName)                                             $errs[] = 'Site name is required.';
+    if (!preg_match('/^[a-zA-Z0-9_\-]{3,30}$/', $adminUser))  $errs[] = 'Admin username must be 3–30 alphanumeric characters (letters, numbers, _ -)';
+    if (!filter_var($adminEmail, FILTER_VALIDATE_EMAIL))       $errs[] = 'Admin email is not valid.';
+    if (strlen($adminPass) < 8)                                $errs[] = 'Admin password must be at least 8 characters.';
+    if ($adminPass !== $adminPass2)                            $errs[] = 'Passwords do not match.';
+
+    // Validate DB-specific fields
+    $dbHost = trim($_POST['db_host'] ?? '127.0.0.1');
+    $dbPort = max(1, min(65535, (int)($_POST['db_port'] ?? 3306)));
+    $dbName = trim($_POST['db_name'] ?? '');
+    $dbUser = trim($_POST['db_user'] ?? '');
+    $dbPass = $_POST['db_pass'] ?? '';
+
+    if ($selDb === 'mysql') {
+        if (!$hasMySQL)   $errs[] = 'PDO MySQL driver is not available on this server.';
+        if (!$dbName)     $errs[] = 'Database name is required for MySQL.';
+        if (!$dbUser)     $errs[] = 'Database username is required for MySQL.';
+
+        // Test connection before proceeding
+        if (!$errs) {
+            try {
+                $testPdo = new PDO(
+                    "mysql:host={$dbHost};port={$dbPort};dbname={$dbName};charset=utf8mb4",
+                    $dbUser, $dbPass,
+                    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 5]
+                );
+                unset($testPdo);
+            } catch (PDOException $e) {
+                $errs[] = 'Could not connect to MySQL: ' . htmlspecialchars($e->getMessage());
+            }
+        }
+    } else {
+        if (!$hasSQLite) $errs[] = 'PDO SQLite driver is not available on this server. Please choose MySQL.';
+    }
+
+    // All good — run installation
+    if (!$errs) {
+        try {
+            /* 1. Write db_config.php */
+            $cfgPath = ROOT . '/includes/db_config.php';
+            if ($selDb === 'mysql') {
+                $cfg = "<?php\n"
+                     . "if (!defined('NEXUS')) { http_response_code(403); exit('Forbidden'); }\n"
+                     . "define('DB_DRIVER',  'mysql');\n"
+                     . "define('DB_HOST',    " . var_export($dbHost,        true) . ");\n"
+                     . "define('DB_PORT',    " . var_export((string)$dbPort, true) . ");\n"
+                     . "define('DB_NAME',    " . var_export($dbName,         true) . ");\n"
+                     . "define('DB_USER',    " . var_export($dbUser,         true) . ");\n"
+                     . "define('DB_PASS',    " . var_export($dbPass,         true) . ");\n"
+                     . "define('DB_CHARSET', 'utf8mb4');\n";
+            } else {
+                $cfg = "<?php\n"
+                     . "if (!defined('NEXUS')) { http_response_code(403); exit('Forbidden'); }\n"
+                     . "define('DB_DRIVER', 'sqlite');\n";
+            }
+            file_put_contents($cfgPath, $cfg);
+            @chmod($cfgPath, 0640);
+
+            /* 2. Build schema */
+            DB::init();
+            $db = DB::connect();
+
+            /* 3. Insert settings */
+            $upsert = DB::isMysql()
+                ? 'INSERT INTO settings (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)'
+                : 'INSERT OR REPLACE INTO settings (key,value) VALUES (?,?)';
+            $si = $db->prepare($upsert);
+            $settingsToInsert = [
+                'site_name'              => $siteName,
+                'site_desc'              => $siteDesc ?: 'A community forum',
+                'allow_reg'              => '1',
+                'topics_per_page'        => '30',
+                'posts_per_page'         => '20',
+                'rate_limit_enabled'     => '0',
+                'rate_limit_count'       => '3',
+                'rate_limit_window'      => '60',
+                'post_captcha_enabled'   => '0',
+                'topic_captcha_enabled'  => '0',
+            ];
+            foreach ($settingsToInsert as $k => $v) {
+                $si->execute([$k, $v]);
+            }
+
+            /* 4. Create admin user */
+            $adminId = DB::insert(
+                'INSERT INTO users (username, email, password, role) VALUES (?, ?, ?, ?)',
+                [$adminUser, $adminEmail, password_hash($adminPass, PASSWORD_BCRYPT, ['cost' => 12]), 'admin']
+            );
+
+            /* 5. Create default categories */
+            $catStmt = $db->prepare('INSERT INTO categories (name, slug, description, color, icon, position) VALUES (?, ?, ?, ?, ?, ?)');
+            $defaultCats = [
+                ['Announcements', 'announcements', 'Important updates from the team',  '#ef4444', '📢', 1],
+                ['General',       'general',       'Talk about anything',               '#3b82f6', '💬', 2],
+                ['Support',       'support',       'Get help from the community',       '#10b981', '🛟', 3],
+                ['Ideas',         'ideas',         'Share your suggestions',            '#8b5cf6', '💡', 4],
+                ['Showcase',      'showcase',      'Show off your projects',            '#f59e0b', '🌟', 5],
+            ];
+            foreach ($defaultCats as $cat) {
+                $catStmt->execute($cat);
+            }
+
+            /* 6. Create welcome topic in General */
+            $generalId = (int) DB::val("SELECT id FROM categories WHERE slug = 'general'");
+            if ($generalId) {
+                $welcomeBody = "# Welcome to {$siteName}!\n\n"
+                    . "This is your new community forum. Here's how to get started:\n\n"
+                    . "## Quick Start\n\n"
+                    . "- Browse the categories in the left sidebar\n"
+                    . "- Click **+ New Topic** to start a discussion\n"
+                    . "- Type **@username** in a post to mention someone\n"
+                    . "- Paste a YouTube, Vimeo, or Spotify URL on its own line to auto-embed it\n"
+                    . "- Like posts to give ⭐ Karma to helpful members\n\n"
+                    . "Enjoy the community! 👋";
+
+                $nowExpr = DB::isMysql() ? 'NOW()' : "datetime('now')";
+                $topicId = DB::insert(
+                    "INSERT INTO topics (title, slug, category_id, user_id, pinned, last_post_at)
+                     VALUES (?, ?, ?, ?, 1, {$nowExpr})",
+                    ["Welcome to {$siteName}!", 'welcome', $generalId, $adminId]
+                );
+                DB::insert(
+                    'INSERT INTO posts (topic_id, user_id, content, post_num) VALUES (?, ?, ?, 1)',
+                    [$topicId, $adminId, $welcomeBody]
+                );
+                DB::run('UPDATE categories SET topic_count = 1, post_count = 1 WHERE id = ?', [$generalId]);
+            }
+
+            /* 7. Create directories */
+            foreach ([DATA, UPLOADS, UPLOADS . '/avatars'] as $dir) {
+                if (!is_dir($dir)) {
+                    mkdir($dir, 0750, true);
+                }
+            }
+
+            /* 8. Security hardening */
+            // Block web access to data/
+            file_put_contents(DATA . '/.htaccess', "Order Deny,Allow\nDeny from all\n");
+
+            // Block PHP execution in uploads/
+            file_put_contents(UPLOADS . '/.htaccess',
+                "Options -Indexes -ExecCGI\n"
+                . "<FilesMatch \"\\.(?i:php|phtml|php3|php4|php5|php7|phar|cgi|pl|sh|exe)$\">\n"
+                . "  Order Allow,Deny\n"
+                . "  Deny from all\n"
+                . "</FilesMatch>\n"
+            );
+
+            // Secure the SQLite file
+            if (!DB::isMysql() && file_exists(DATA . '/forum.db')) {
+                @chmod(DATA . '/forum.db', 0640);
+            }
+
+            /* 9. Write installed.lock */
+            file_put_contents(DATA . '/installed.lock', json_encode([
+                'installed_at' => date('c'),
+                'db_driver'    => $selDb,
+                'php_version'  => PHP_VERSION,
+            ]));
+            @chmod(DATA . '/installed.lock', 0640);
+
+            /* Done */
+            $step = 3;
+            $info = ['username' => $adminUser, 'db' => $selDb];
+
+        } catch (Throwable $ex) {
+            $errs[] = 'Installation error: ' . htmlspecialchars($ex->getMessage());
+            // Roll back config file if install failed
+            if (isset($cfgPath) && file_exists($cfgPath)) {
+                @unlink($cfgPath);
+            }
+        }
+    }
+}
+
+/* ── HTML helpers ────────────────────────────────────────── */
+function esc(mixed $v): string {
+    return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+}
+
+?><!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Install — Nexus Forum</title>
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
+  <link rel="stylesheet" href="<?= BASE ?>/public/css/main.css">
+  <style>
+    body {
+      background: linear-gradient(135deg, #1e40af 0%, #0f172a 100%);
+      min-height: 100vh;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      padding: 24px;
+      font-family: 'Inter', sans-serif;
+    }
+    .card {
+      background: #fff;
+      border-radius: 16px;
+      padding: 40px;
+      max-width: 580px;
+      width: 100%;
+      box-shadow: 0 25px 60px rgba(0,0,0,.3);
+    }
+    .logo-row {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      margin-bottom: 32px;
+    }
+    .logo-mark {
+      width: 46px;
+      height: 46px;
+      background: #3b82f6;
+      color: #fff;
+      border-radius: 12px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 24px;
+      font-weight: 700;
+      flex-shrink: 0;
+    }
+    .logo-text { font-size: 20px; font-weight: 700; color: #0f172a; }
+    .logo-sub  { font-size: 13px; color: #64748b; }
+
+    /* Steps */
+    .steps { display: flex; margin-bottom: 32px; }
+    .step  { flex: 1; text-align: center; position: relative; }
+    .step::after { content: ''; position: absolute; top: 14px; left: 50%; width: 100%; height: 2px; background: #e2e8f0; z-index: 0; }
+    .step:last-child::after { display: none; }
+    .step-n {
+      width: 30px; height: 30px; border-radius: 50%;
+      display: flex; align-items: center; justify-content: center;
+      font-size: 13px; font-weight: 700; margin: 0 auto 6px;
+      position: relative; z-index: 1;
+    }
+    .step.done .step-n { background: #22c55e; color: #fff; }
+    .step.active .step-n { background: #3b82f6; color: #fff; box-shadow: 0 0 0 4px #dbeafe; }
+    .step.todo .step-n  { background: #e2e8f0; color: #94a3b8; }
+    .step-label { font-size: 11px; font-weight: 600; color: #94a3b8; }
+    .step.active .step-label { color: #3b82f6; }
+    .step.done .step-label   { color: #22c55e; }
+
+    /* Req table */
+    .req-row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid #f1f5f9; font-size: 14px; }
+    .req-row:last-child { border-bottom: none; }
+    .ok   { color: #22c55e; font-weight: 700; }
+    .fail { color: #ef4444; font-weight: 700; }
+
+    /* DB switcher */
+    .db-switch { display: flex; border: 2px solid #e2e8f0; border-radius: 10px; overflow: hidden; margin-bottom: 18px; }
+    .db-btn {
+      flex: 1; padding: 14px 12px; text-align: center; cursor: pointer;
+      background: #f8fafc; border: none; font-family: inherit;
+      font-size: 14px; font-weight: 600; color: #64748b;
+      transition: all .2s; line-height: 1.4;
+    }
+    .db-btn:first-child { border-right: 2px solid #e2e8f0; }
+    .db-btn.active { background: #3b82f6; color: #fff; }
+    .db-btn .db-sub { font-size: 11px; font-weight: 400; opacity: .75; display: block; margin-top: 2px; }
+    .db-panel { display: none; }
+    .db-panel.visible { display: block; }
+
+    /* Notices */
+    .notice { border-radius: 8px; padding: 11px 14px; font-size: 13px; margin-bottom: 14px; }
+    .notice-green  { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; }
+    .notice-yellow { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; }
+    .notice-blue   { background: #eff6ff; border: 1px solid #bfdbfe; color: #1e40af; }
+
+    /* Form sections */
+    .fsection { margin-bottom: 22px; }
+    .fsection legend, .fsection-title {
+      display: block; font-weight: 700; font-size: 13px; color: #374151;
+      padding-bottom: 8px; border-bottom: 2px solid #f1f5f9; margin-bottom: 14px; width: 100%;
+    }
+    .fg { margin-bottom: 14px; }
+    .fg label { display: block; font-size: 13px; font-weight: 600; color: #374151; margin-bottom: 5px; }
+    .fg label small { font-weight: 400; color: #94a3b8; }
+    .fi {
+      width: 100%; padding: 9px 12px; border: 1px solid #d1d5db; border-radius: 8px;
+      font-size: 14px; font-family: inherit; color: #111827; outline: none; transition: border-color .18s;
+    }
+    .fi:focus { border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59,130,246,.12); }
+    .grid-2 { display: grid; grid-template-columns: 1fr 90px; gap: 10px; }
+
+    /* Buttons */
+    .btn-install {
+      display: flex; align-items: center; justify-content: center; gap: 8px;
+      width: 100%; padding: 13px; background: #3b82f6; color: #fff; border: none;
+      border-radius: 10px; font-size: 16px; font-weight: 700; cursor: pointer;
+      font-family: inherit; transition: all .2s; margin-top: 6px;
+    }
+    .btn-install:hover { background: #2563eb; transform: translateY(-1px); }
+    .btn-next {
+      display: flex; align-items: center; justify-content: center;
+      width: 100%; padding: 12px; background: #3b82f6; color: #fff; border-radius: 10px;
+      text-decoration: none; font-size: 15px; font-weight: 600; transition: all .2s;
+    }
+    .btn-next:hover { background: #2563eb; text-decoration: none; color: #fff; }
+
+    /* Error alert */
+    .alert-err { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 8px; padding: 12px 16px; font-size: 14px; margin-bottom: 18px; }
+
+    /* Success */
+    .success-hero { text-align: center; padding: 10px 0 20px; }
+    .success-hero .emoji { font-size: 4rem; line-height: 1; margin-bottom: 12px; }
+    .success-hero h2 { font-size: 1.5rem; font-weight: 800; color: #0f172a; margin-bottom: 6px; }
+    .success-hero p  { color: #64748b; font-size: 15px; }
+    .info-table { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 0; margin-bottom: 20px; overflow: hidden; }
+    .info-row { display: flex; justify-content: space-between; align-items: center; padding: 11px 16px; border-bottom: 1px solid #f1f5f9; font-size: 14px; }
+    .info-row:last-child { border-bottom: none; }
+    .info-row .label { font-weight: 600; color: #374151; }
+    .info-row code { background: #e2e8f0; padding: 2px 8px; border-radius: 5px; font-size: 13px; }
+    .security-list { list-style: none; padding: 0; margin: 8px 0 0; }
+    .security-list li { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; margin-bottom: 6px; line-height: 1.4; }
+  </style>
+</head>
+<body>
+<div class="card">
+
+  <!-- Logo -->
+  <div class="logo-row">
+    <div class="logo-mark">N</div>
+    <div>
+      <div class="logo-text">Nexus Forum</div>
+      <div class="logo-sub">Installation Wizard</div>
+    </div>
+  </div>
+
+  <!-- Steps indicator -->
+  <div class="steps">
+    <?php
+    $stepDefs = ['Requirements', 'Configure', 'Complete'];
+    foreach ($stepDefs as $i => $label):
+      $n = $i + 1;
+      $cls = $step > $n ? 'done' : ($step === $n ? 'active' : 'todo');
+    ?>
+      <div class="step <?= $cls ?>">
+        <div class="step-n"><?= $step > $n ? '✓' : $n ?></div>
+        <div class="step-label"><?= $label ?></div>
+      </div>
+    <?php endforeach; ?>
+  </div>
+
+  <?php if ($step === 1): /* ─── STEP 1: Requirements ─── */ ?>
+
+    <h2 style="font-size:1.25rem;font-weight:700;margin-bottom:6px">System Check</h2>
+    <p style="color:#64748b;font-size:14px;margin-bottom:20px">Checking your server before we begin.</p>
+
+    <?php foreach ($reqs as $label => $ok): ?>
+      <div class="req-row">
+        <span><?= esc($label) ?></span>
+        <span class="<?= $ok ? 'ok' : 'fail' ?>"><?= $ok ? '✓ OK' : '✗ FAIL' ?></span>
+      </div>
+    <?php endforeach; ?>
+
+    <div style="margin-top:8px;padding:10px 0;border-top:1px solid #f1f5f9">
+      <div class="req-row">
+        <span>SQLite3 (PDO)</span>
+        <span class="<?= $hasSQLite ? 'ok' : 'fail' ?>"><?= $hasSQLite ? '✓ Available' : '✗ Not available' ?></span>
+      </div>
+      <div class="req-row">
+        <span>MySQL / MariaDB (PDO)</span>
+        <span class="<?= $hasMySQL ? 'ok' : 'fail' ?>"><?= $hasMySQL ? '✓ Available' : '✗ Not available' ?></span>
+      </div>
+    </div>
+
+    <div style="margin-top:22px">
+      <?php if ($allOk): ?>
+        <a href="?step=2" class="btn-next">Continue to Configuration →</a>
+      <?php else: ?>
+        <div class="alert-err">Please fix the issues above, then reload this page.</div>
+      <?php endif; ?>
+    </div>
+
+  <?php elseif ($step === 2): /* ─── STEP 2: Configure ─── */ ?>
+
+    <h2 style="font-size:1.25rem;font-weight:700;margin-bottom:6px">Configure Your Forum</h2>
+    <p style="color:#64748b;font-size:14px;margin-bottom:22px">Fill in all fields below and click <strong>Install</strong>.</p>
+
+    <?php if ($errs): ?>
+      <div class="alert-err">
+        <?php foreach ($errs as $e): ?>
+          <div>• <?= esc($e) ?></div>
+        <?php endforeach; ?>
+      </div>
+    <?php endif; ?>
+
+    <form method="POST" action="?step=2">
+      <input type="hidden" name="install_submit" value="1">
+      <input type="hidden" name="db_type" id="dbTypeHidden" value="<?= esc($selDb) ?>">
+
+      <!-- Database selection -->
+      <div class="fsection">
+        <span class="fsection-title">1. Database Engine</span>
+
+        <div class="db-switch">
+          <button type="button" class="db-btn <?= $selDb !== 'mysql' ? 'active' : '' ?>"
+                  id="btnSQLite" onclick="switchDb('sqlite')">
+            🗃️ SQLite3
+            <span class="db-sub">Easiest — no setup needed</span>
+          </button>
+          <button type="button" class="db-btn <?= $selDb === 'mysql' ? 'active' : '' ?>"
+                  id="btnMySQL" onclick="switchDb('mysql')">
+            🐬 MySQL / MariaDB
+            <span class="db-sub">Recommended for production</span>
+          </button>
+        </div>
+
+        <!-- SQLite panel -->
+        <div id="panelSQLite" class="db-panel <?= $selDb !== 'mysql' ? 'visible' : '' ?>">
+          <?php if ($hasSQLite): ?>
+            <div class="notice notice-green">
+              ✓ SQLite will be automatically created at <code>data/forum.db</code>. No extra configuration needed.
+            </div>
+          <?php else: ?>
+            <div class="notice notice-yellow">
+              ⚠️ PDO SQLite is not available on this server. Please switch to MySQL.
+            </div>
+          <?php endif; ?>
+        </div>
+
+        <!-- MySQL panel -->
+        <div id="panelMySQL" class="db-panel <?= $selDb === 'mysql' ? 'visible' : '' ?>">
+          <?php if ($hasMySQL): ?>
+            <div class="grid-2">
+              <div class="fg">
+                <label>Host</label>
+                <input type="text" name="db_host" class="fi" value="<?= esc($_POST['db_host'] ?? '127.0.0.1') ?>" placeholder="127.0.0.1">
+              </div>
+              <div class="fg">
+                <label>Port</label>
+                <input type="number" name="db_port" class="fi" value="<?= esc($_POST['db_port'] ?? '3306') ?>" min="1" max="65535">
+              </div>
+            </div>
+            <div class="fg">
+              <label>Database Name</label>
+              <input type="text" name="db_name" class="fi" value="<?= esc($_POST['db_name'] ?? '') ?>" placeholder="nexus_forum">
+            </div>
+            <div class="fg">
+              <label>Database Username</label>
+              <input type="text" name="db_user" class="fi" value="<?= esc($_POST['db_user'] ?? '') ?>" placeholder="nexus_user">
+            </div>
+            <div class="fg">
+              <label>Database Password</label>
+              <input type="password" name="db_pass" class="fi" placeholder="Your database password">
+            </div>
+            <div class="notice notice-blue" style="font-size:12px">
+              <strong>Create the database first if you haven't:</strong><br>
+              <code style="font-size:11px">CREATE DATABASE nexus_forum CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;</code>
+            </div>
+          <?php else: ?>
+            <div class="notice notice-yellow">⚠️ PDO MySQL is not available on this server.</div>
+          <?php endif; ?>
+        </div>
+      </div>
+
+      <!-- Site details -->
+      <div class="fsection">
+        <span class="fsection-title">2. Site Details</span>
+        <div class="fg">
+          <label>Site Name <span style="color:#ef4444">*</span></label>
+          <input type="text" name="site_name" class="fi" required
+                 value="<?= esc($_POST['site_name'] ?? 'My Forum') ?>" placeholder="My Community">
+        </div>
+        <div class="fg">
+          <label>Description <small>(optional)</small></label>
+          <input type="text" name="site_desc" class="fi"
+                 value="<?= esc($_POST['site_desc'] ?? '') ?>" placeholder="A place for great discussions">
+        </div>
+      </div>
+
+      <!-- Admin account -->
+      <div class="fsection">
+        <span class="fsection-title">3. Admin Account</span>
+        <div class="fg">
+          <label>Username <span style="color:#ef4444">*</span> <small>(letters, numbers, _ -)</small></label>
+          <input type="text" name="admin_user" class="fi" required
+                 value="<?= esc($_POST['admin_user'] ?? '') ?>" placeholder="admin"
+                 minlength="3" maxlength="30" autocomplete="off">
+        </div>
+        <div class="fg">
+          <label>Email Address <span style="color:#ef4444">*</span></label>
+          <input type="email" name="admin_email" class="fi" required
+                 value="<?= esc($_POST['admin_email'] ?? '') ?>" placeholder="admin@example.com">
+        </div>
+        <div class="fg">
+          <label>Password <span style="color:#ef4444">*</span> <small>(min. 8 characters)</small></label>
+          <input type="password" name="admin_pass" class="fi" required
+                 minlength="8" placeholder="Choose a strong password" autocomplete="new-password">
+        </div>
+        <div class="fg">
+          <label>Confirm Password <span style="color:#ef4444">*</span></label>
+          <input type="password" name="admin_pass2" class="fi" required
+                 minlength="8" placeholder="Repeat your password" autocomplete="new-password">
+        </div>
+      </div>
+
+      <button type="submit" class="btn-install">
+        🚀 Install Nexus Forum
+      </button>
+    </form>
+
+  <?php elseif ($step === 3): /* ─── STEP 3: Done ─── */ ?>
+
+    <div class="success-hero">
+      <div class="emoji">🎉</div>
+      <h2>Installation Complete!</h2>
+      <p>Your forum is ready. Log in with your admin credentials below.</p>
+    </div>
+
+    <div class="info-table">
+      <div class="info-row">
+        <span class="label">Forum URL</span>
+        <a href="<?= BASE ?>/"><?= BASE ?: '/' ?></a>
+      </div>
+      <div class="info-row">
+        <span class="label">Admin Panel</span>
+        <a href="<?= BASE ?>/admin/"><?= BASE ?>/admin/</a>
+      </div>
+      <div class="info-row">
+        <span class="label">Admin Username</span>
+        <code><?= esc($info['username'] ?? '') ?></code>
+      </div>
+      <div class="info-row">
+        <span class="label">Database</span>
+        <span><?= ($info['db'] ?? 'sqlite') === 'mysql' ? '🐬 MySQL / MariaDB' : '🗃️ SQLite3' ?></span>
+      </div>
+    </div>
+
+    <div class="notice notice-blue">
+      <strong>🔒 Post-Install Security Checklist</strong>
+      <ul class="security-list">
+        <li>✅ <code>data/.htaccess</code> configured — web access blocked automatically</li>
+        <li>✅ <code>uploads/.htaccess</code> configured — PHP execution blocked automatically</li>
+        <li>⚠️ <strong>Delete or restrict the <code>install/</code> directory</strong> — it's no longer needed</li>
+        <li>⚠️ Enable HTTPS on your web server</li>
+        <?php if (($info['db'] ?? '') !== 'mysql'): ?>
+          <li>📦 Back up <code>data/forum.db</code> regularly</li>
+        <?php endif; ?>
+      </ul>
+    </div>
+
+    <a href="<?= BASE ?>/" class="btn-next" style="margin-top:8px">Visit Your Forum →</a>
+
+  <?php endif; ?>
+
+</div>
+
+<script>
+function switchDb(type) {
+  // Update hidden input
+  document.getElementById('dbTypeHidden').value = type;
+
+  // Update button styles
+  document.getElementById('btnSQLite').className = 'db-btn' + (type === 'sqlite' ? ' active' : '');
+  document.getElementById('btnMySQL').className  = 'db-btn' + (type === 'mysql'  ? ' active' : '');
+
+  // Show/hide panels
+  document.getElementById('panelSQLite').className = 'db-panel' + (type === 'sqlite' ? ' visible' : '');
+  document.getElementById('panelMySQL').className  = 'db-panel' + (type === 'mysql'  ? ' visible' : '');
+}
+</script>
+</body>
+</html>
diff --git a/messages/chat.php b/messages/chat.php
new file mode 100644
index 0000000..61a8772
--- /dev/null
+++ b/messages/chat.php
@@ -0,0 +1,402 @@
+<?php
+/**
+ * Live Chat — conversation view with real-time polling
+ */
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_login();
+
+$withUser = get('with');
+$other    = null;
+
+if ($withUser) {
+    $other = DB::row('SELECT id,username,avatar,role,karma,last_seen FROM users WHERE username=?', [$withUser]);
+    if (!$other) render_404();
+    if ((int)$other['id'] === (int)$USER['id']) go('messages/chat.php');
+}
+
+$PAGE_TITLE = $other ? 'Chat with @' . $other['username'] : 'Messages';
+include __DIR__ . '/../views/partials/layout.php';
+?>
+<div class="chat-shell">
+
+  <!-- Sidebar: conversation list -->
+  <aside class="chat-aside" id="chatAside">
+    <div class="chat-aside-head">
+      <h2>💬 Messages</h2>
+      <a href="<?= u('messages/chat.php') ?>" class="chat-new-btn" title="New conversation">
+        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <path d="M12 5v14M5 12h14"/>
+        </svg>
+      </a>
+    </div>
+    <div class="chat-aside-search">
+      <input type="text" id="convSearch" class="chat-search-inp" placeholder="Search people…" autocomplete="off">
+      <div id="convSearchResults" class="chat-search-results"></div>
+    </div>
+    <div class="chat-conv-list" id="convList">
+      <div class="chat-loading">Loading…</div>
+    </div>
+  </aside>
+
+  <!-- Main chat area -->
+  <div class="chat-main" id="chatMain">
+    <?php if ($other): ?>
+      <!-- Chat header -->
+      <div class="chat-head">
+        <div class="chat-head-left">
+          <button class="chat-back-btn" onclick="history.back()">←</button>
+          <?php if ($other['avatar']): ?>
+            <img src="<?= e($other['avatar']) ?>" class="av-md" alt="">
+          <?php else: ?>
+            <span class="av-md av-init"><?= strtoupper($other['username'][0]) ?></span>
+          <?php endif; ?>
+          <div>
+            <a href="<?= u('users/profile.php?u=' . urlencode($other['username'])) ?>" class="chat-head-name">
+              @<?= e($other['username']) ?>
+            </a>
+            <div class="chat-head-status" id="chatStatus">
+              <span class="status-dot" id="statusDot"></span>
+              <span id="statusTxt">Loading…</span>
+            </div>
+          </div>
+        </div>
+        <div class="chat-head-right">
+          <a href="<?= u('users/profile.php?u=' . urlencode($other['username'])) ?>" class="btn-ghost btn-sm">View Profile</a>
+        </div>
+      </div>
+
+      <!-- Messages area -->
+      <div class="chat-messages" id="chatMessages">
+        <div class="chat-loading-msgs">
+          <div class="chat-spinner"></div>
+          Loading messages…
+        </div>
+      </div>
+
+      <!-- Typing + input -->
+      <div class="chat-input-area">
+        <div class="chat-input-wrap">
+          <textarea id="chatInput" class="chat-input" placeholder="Write a message… (Enter to send, Shift+Enter for newline)"
+                    rows="1" maxlength="2000"></textarea>
+          <button id="chatSendBtn" class="chat-send-btn" title="Send">
+            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
+              <path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z"/>
+            </svg>
+          </button>
+        </div>
+        <div class="chat-input-meta">
+          <span id="chatCharCount" class="chat-char">0 / 2000</span>
+          <span class="chat-hint">Enter ↵ to send · Shift+Enter for new line</span>
+        </div>
+      </div>
+
+    <?php else: ?>
+      <!-- No conversation selected -->
+      <div class="chat-empty-state">
+        <div class="chat-empty-icon">💬</div>
+        <h2>Your Messages</h2>
+        <p>Select a conversation or start a new one</p>
+        <div class="chat-new-search">
+          <input type="text" id="newChatSearch" class="fi" placeholder="Search for a user to message…" autocomplete="off">
+          <div id="newChatResults" class="chat-search-results chat-search-results-lg"></div>
+        </div>
+      </div>
+    <?php endif; ?>
+  </div>
+
+</div>
+
+<script>
+(function() {
+  var OTHER_ID   = <?= $other ? (int)$other['id'] : 'null' ?>;
+  var OTHER_NAME = <?= $other ? json_encode($other['username']) : 'null' ?>;
+  var MY_ID      = NX.user ? NX.user.id : null;
+  var lastMsgId  = 0;
+  var pollTimer  = null;
+  var sending    = false;
+
+  /* ── Helpers ─────────────────────────────────────────── */
+  function esc(s) {
+    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
+                    .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
+  }
+
+  function timeStr(ts) {
+    if (!ts) return '';
+    var d = new Date(ts.replace(' ','T') + 'Z');
+    var now = new Date();
+    var diff = (now - d) / 1000;
+    if (diff < 60)    return 'just now';
+    if (diff < 3600)  return Math.floor(diff/60) + 'm ago';
+    if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
+    return d.toLocaleDateString();
+  }
+
+  function avatarHtml(name, av, size) {
+    size = size || 'av-sm';
+    if (av) return '<img src="'+esc(av)+'" class="'+size+'" alt="">';
+    return '<span class="'+size+' av-init">'+esc(name[0].toUpperCase())+'</span>';
+  }
+
+  /* ── Render a single message bubble ────────────────── */
+  function renderBubble(m) {
+    var mine = (parseInt(m.sender_id) === MY_ID);
+    var ts   = timeStr(m.created_at);
+    return '<div class="chat-bubble-row '+(mine?'mine':'theirs')+'" data-id="'+m.id+'">'
+      + (mine ? '' : '<div class="chat-av">'+avatarHtml(m.sender_name, m.sender_av)+'</div>')
+      + '<div class="chat-bubble-wrap">'
+      +   '<div class="chat-bubble">'
+      +     '<div class="chat-bubble-body">'+esc(m.body).replace(/\n/g,'<br>')+'</div>'
+      +     '<div class="chat-bubble-time">'+ts+'</div>'
+      +   '</div>'
+      +   (mine ? '<button class="chat-del-btn" onclick="deleteMsg('+m.id+',this)" title="Delete">✕</button>' : '')
+      + '</div>'
+      + (mine ? '<div class="chat-av">'+avatarHtml(m.sender_name, m.sender_av)+'</div>' : '')
+      + '</div>';
+  }
+
+  /* ── Load conversation ─────────────────────────────── */
+  function loadConversation() {
+    if (!OTHER_ID) return;
+    var fd = new FormData();
+    fd.append('action','load'); fd.append('other_id',OTHER_ID); fd.append('csrf',NX.csrf);
+    fetch(NX.base+'/api/chat.php', {method:'POST', body:fd})
+      .then(function(r){return r.json();})
+      .then(function(d){
+        if (!d.ok) return;
+        var box = document.getElementById('chatMessages');
+        if (d.messages.length === 0) {
+          box.innerHTML = '<div class="chat-no-msgs">No messages yet. Say hello! 👋</div>';
+        } else {
+          box.innerHTML = d.messages.map(renderBubble).join('');
+          lastMsgId = d.messages[d.messages.length-1].id;
+          scrollToBottom(true);
+        }
+        updateStatus(d.other);
+        startPolling();
+      });
+  }
+
+  /* ── Update online status ─────────────────────────── */
+  function updateStatus(other) {
+    if (!other) return;
+    var dot = document.getElementById('statusDot');
+    var txt = document.getElementById('statusTxt');
+    if (!dot || !txt) return;
+    var ls  = new Date((other.last_seen||'').replace(' ','T')+'Z');
+    var diff = (Date.now() - ls) / 1000;
+    if (diff < 300) {
+      dot.className = 'status-dot online';
+      txt.textContent = 'Online';
+    } else {
+      dot.className = 'status-dot offline';
+      txt.textContent = 'Last seen ' + timeStr(other.last_seen);
+    }
+  }
+
+  /* ── Scroll to bottom ─────────────────────────────── */
+  function scrollToBottom(force) {
+    var box = document.getElementById('chatMessages');
+    if (!box) return;
+    var atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 80;
+    if (force || atBottom) box.scrollTop = box.scrollHeight;
+  }
+
+  /* ── Poll for new messages ────────────────────────── */
+  function poll() {
+    if (!OTHER_ID) return;
+    var fd = new FormData();
+    fd.append('action','poll'); fd.append('other_id',OTHER_ID);
+    fd.append('since_id',lastMsgId); fd.append('csrf',NX.csrf);
+    fetch(NX.base+'/api/chat.php', {method:'POST', body:fd})
+      .then(function(r){return r.json();})
+      .then(function(d){
+        if (!d.ok || !d.messages.length) return;
+        var box = document.getElementById('chatMessages');
+        var noMsg = box.querySelector('.chat-no-msgs');
+        if (noMsg) noMsg.remove();
+        d.messages.forEach(function(m){
+          if (!box.querySelector('[data-id="'+m.id+'"]')) {
+            box.insertAdjacentHTML('beforeend', renderBubble(m));
+            lastMsgId = Math.max(lastMsgId, m.id);
+          }
+        });
+        scrollToBottom();
+      });
+  }
+
+  function startPolling() {
+    clearInterval(pollTimer);
+    pollTimer = setInterval(poll, 2500);
+  }
+
+  /* ── Send message ────────────────────────────────── */
+  function sendMessage() {
+    if (sending) return;
+    var inp  = document.getElementById('chatInput');
+    var body = inp.value.trim();
+    if (!body || !OTHER_ID) return;
+    sending = true;
+    var btn = document.getElementById('chatSendBtn');
+    btn.disabled = true;
+    inp.disabled = true;
+
+    var fd = new FormData();
+    fd.append('action','send'); fd.append('to_id',OTHER_ID);
+    fd.append('body',body); fd.append('csrf',NX.csrf);
+    fetch(NX.base+'/api/chat.php', {method:'POST', body:fd})
+      .then(function(r){return r.json();})
+      .then(function(d){
+        sending = false; btn.disabled = false; inp.disabled = false; inp.focus();
+        if (d.ok && d.message) {
+          var box = document.getElementById('chatMessages');
+          var noMsg = box.querySelector('.chat-no-msgs');
+          if (noMsg) noMsg.remove();
+          box.insertAdjacentHTML('beforeend', renderBubble(d.message));
+          lastMsgId = Math.max(lastMsgId, d.message.id);
+          scrollToBottom(true);
+          inp.value = ''; autoResize(inp);
+          updateCharCount();
+        } else {
+          toast(d.error || 'Failed to send', 'err');
+        }
+      })
+      .catch(function(){ sending=false; btn.disabled=false; inp.disabled=false; toast('Network error','err'); });
+  }
+
+  /* ── Delete message ──────────────────────────────── */
+  window.deleteMsg = function(id, btn) {
+    if (!confirm('Delete this message?')) return;
+    var fd = new FormData();
+    fd.append('action','delete'); fd.append('msg_id',id); fd.append('csrf',NX.csrf);
+    fetch(NX.base+'/api/chat.php', {method:'POST', body:fd})
+      .then(function(r){return r.json();})
+      .then(function(d){
+        if (d.ok) btn.closest('.chat-bubble-row').remove();
+        else toast(d.error,'err');
+      });
+  };
+
+  /* ── Input auto-resize ───────────────────────────── */
+  function autoResize(ta) {
+    ta.style.height = 'auto';
+    ta.style.height = Math.min(ta.scrollHeight, 160) + 'px';
+  }
+  function updateCharCount() {
+    var inp = document.getElementById('chatInput');
+    var cnt = document.getElementById('chatCharCount');
+    if (inp && cnt) {
+      var n = inp.value.length;
+      cnt.textContent = n + ' / 2000';
+      cnt.style.color = n > 1800 ? '#ef4444' : '';
+    }
+  }
+
+  /* ── Load conversation list ──────────────────────── */
+  function loadConvList() {
+    var fd = new FormData();
+    fd.append('action','conversations'); fd.append('csrf',NX.csrf);
+    fetch(NX.base+'/api/chat.php', {method:'POST', body:fd})
+      .then(function(r){return r.json();})
+      .then(function(d){
+        var list = document.getElementById('convList');
+        if (!list) return;
+        if (!d.ok || !d.conversations.length) {
+          list.innerHTML = '<div class="chat-empty-conv">No conversations yet</div>';
+          return;
+        }
+        list.innerHTML = d.conversations.map(function(c){
+          var isOther = parseInt(c.sender_id) === MY_ID ? false : true;
+          var name    = c.other_name;
+          var av      = c.other_av;
+          var active  = OTHER_NAME && OTHER_NAME === name ? ' active' : '';
+          var unread  = parseInt(c.unread_count) > 0;
+          return '<a href="'+NX.base+'/messages/chat.php?with='+encodeURIComponent(name)+'" class="conv-item'+active+'">'
+            + avatarHtml(name, av)
+            + '<div class="conv-body">'
+            +   '<div class="conv-name">@'+esc(name)+(unread?'<span class="conv-unread">'+c.unread_count+'</span>':'')+'</div>'
+            +   '<div class="conv-preview">'+(parseInt(c.sender_id)===MY_ID?'You: ':'')+esc(c.body.substring(0,50))+(c.body.length>50?'…':'')+'</div>'
+            + '</div>'
+            + '<div class="conv-time">'+timeStr(c.created_at)+'</div>'
+            + '</a>';
+        }).join('');
+      });
+  }
+
+  /* ── Conversation search (sidebar) ──────────────── */
+  function setupConvSearch() {
+    var inp = document.getElementById('convSearch');
+    if (!inp) return;
+    var res = document.getElementById('convSearchResults');
+    var timer;
+    inp.addEventListener('input', function(){
+      clearTimeout(timer);
+      var q = this.value.trim();
+      if (q.length < 1) { res.style.display='none'; return; }
+      timer = setTimeout(function(){
+        fetch(NX.base+'/api/search_users.php?q='+encodeURIComponent(q))
+          .then(function(r){return r.json();})
+          .then(function(rows){
+            if (!rows.length) { res.style.display='none'; return; }
+            res.innerHTML = rows.map(function(u){
+              return '<a class="chat-sug-item" href="'+NX.base+'/messages/chat.php?with='+encodeURIComponent(u.username)+'">'
+                + avatarHtml(u.username, u.avatar)
+                + '<span>@'+esc(u.username)+'</span></a>';
+            }).join('');
+            res.style.display='block';
+          });
+      }, 200);
+    });
+    document.addEventListener('click', function(e){
+      if (!inp.contains(e.target)) res.style.display='none';
+    });
+  }
+
+  /* ── New chat search (empty state) ──────────────── */
+  function setupNewChatSearch() {
+    var inp = document.getElementById('newChatSearch');
+    if (!inp) return;
+    var res = document.getElementById('newChatResults');
+    var timer;
+    inp.addEventListener('input', function(){
+      clearTimeout(timer);
+      var q = this.value.trim();
+      if (q.length < 1) { res.style.display='none'; return; }
+      timer = setTimeout(function(){
+        fetch(NX.base+'/api/search_users.php?q='+encodeURIComponent(q))
+          .then(function(r){return r.json();})
+          .then(function(rows){
+            if (!rows.length) { res.style.display='none'; return; }
+            res.innerHTML = rows.map(function(u){
+              return '<a class="chat-sug-item" href="'+NX.base+'/messages/chat.php?with='+encodeURIComponent(u.username)+'">'
+                + avatarHtml(u.username, u.avatar, 'av-md')
+                + '<div><div style="font-weight:600">@'+esc(u.username)+'</div></div></a>';
+            }).join('');
+            res.style.display='block';
+          });
+      }, 200);
+    });
+  }
+
+  /* ── Wire up input ───────────────────────────────── */
+  var inp = document.getElementById('chatInput');
+  if (inp) {
+    inp.addEventListener('keydown', function(e){
+      if (e.key==='Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
+    });
+    inp.addEventListener('input', function(){ autoResize(this); updateCharCount(); });
+    document.getElementById('chatSendBtn').addEventListener('click', sendMessage);
+  }
+
+  /* ── Init ─────────────────────────────────────────── */
+  loadConvList();
+  setupConvSearch();
+  setupNewChatSearch();
+  if (OTHER_ID) loadConversation();
+
+  // Refresh conv list every 10s
+  setInterval(loadConvList, 10000);
+
+})();
+</script>
+<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>
diff --git a/messages/compose.php b/messages/compose.php
new file mode 100644
index 0000000..8f86dc6
--- /dev/null
+++ b/messages/compose.php
@@ -0,0 +1,231 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_login();
+
+$toUser = get('to');
+$errs   = [];
+
+// Online users for quick selection
+$since       = DB::sinceSeconds(900);
+$onlineUsers = DB::rows(
+    "SELECT id, username, avatar, role FROM users
+     WHERE last_seen >= $since AND id != ? AND suspended = 0
+     ORDER BY last_seen DESC LIMIT 12",
+    [$USER['id']]
+);
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    if (!csrf_ok()) { $errs[] = 'Invalid request.'; }
+    else {
+        $to      = sanitise(post('to'));
+        $subject = sanitise(post('subject'));
+        $body    = sanitise(post('body'));
+
+        if (!$to)                     $errs[] = 'Recipient required.';
+        if (!$body)                   $errs[] = 'Message body required.';
+        if (strlen($body) > 5000)     $errs[] = 'Message too long (max 5000 chars).';
+
+        if (!$errs) {
+            $recipient = DB::row('SELECT * FROM users WHERE username=?', [$to]);
+            if (!$recipient)                          $errs[] = 'User "@' . e($to) . '" not found.';
+            elseif ($recipient['id'] === $USER['id']) $errs[] = 'You cannot message yourself.';
+        }
+
+        if (!$errs) {
+            $newId = DB::insert(
+                'INSERT INTO messages (sender_id,receiver_id,subject,body) VALUES (?,?,?,?)',
+                [$USER['id'], $recipient['id'], $subject, $body]
+            );
+            add_notification((int)$recipient['id'], 'message', [
+                'from'    => $USER['username'],
+                'from_id' => $USER['id'],
+                'subject' => mb_substr($body, 0, 60) . (mb_strlen($body) > 60 ? '…' : ''),
+            ]);
+            go('messages/view.php?id=' . $newId);
+        }
+    }
+}
+
+$PAGE_TITLE = 'New Message';
+include __DIR__ . '/../views/partials/layout.php';
+?>
+
+<div class="cp-layout">
+
+  <!-- ── Compose form ─────────────────────────── -->
+  <div class="cp-main">
+    <div class="form-card">
+      <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
+        <h1 style="font-size:1.2rem;font-weight:700;margin:0">✉️ New Message</h1>
+        <a href="<?= u('messages/') ?>" class="btn-ghost btn-sm">← Inbox</a>
+      </div>
+
+      <?php if (!empty($errs)): ?>
+        <div class="alert err"><?= implode('<br>', array_map('e', $errs)) ?></div>
+      <?php endif; ?>
+
+      <form method="POST" id="composeForm">
+        <?= csrf_input() ?>
+
+        <!-- To field with autocomplete -->
+        <div class="fg">
+          <label>To <span class="req">*</span></label>
+          <div class="to-input-wrap" style="position:relative">
+            <span class="at-prefix">@</span>
+            <input type="text" name="to" id="toInput" class="fi" required
+                   value="<?= e($_POST['to'] ?? $toUser ?? '') ?>"
+                   placeholder="username" autocomplete="off">
+            <div id="toSuggestions" class="to-suggestions"></div>
+          </div>
+        </div>
+
+        <!-- Subject -->
+        <div class="fg">
+          <label>Subject <small>(optional)</small></label>
+          <input type="text" name="subject" class="fi" maxlength="150"
+                 value="<?= e($_POST['subject'] ?? '') ?>" placeholder="What's this about?">
+        </div>
+
+        <!-- Body -->
+        <div class="fg">
+          <label>Message <span class="req">*</span></label>
+          <textarea name="body" id="msgBody" class="fi" rows="10" required maxlength="5000"
+                    placeholder="Write your message…"><?= e($_POST['body'] ?? '') ?></textarea>
+          <div style="display:flex;justify-content:space-between;margin-top:4px">
+            <span class="hint">Max 5000 characters</span>
+            <span class="hint" id="bodyCount">0 / 5000</span>
+          </div>
+        </div>
+
+        <div class="form-actions">
+          <a href="<?= u('messages/') ?>" class="btn-ghost">Cancel</a>
+          <button type="submit" class="btn-primary">
+            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z"/></svg>
+            Send Message
+          </button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ── Online people panel ────────────────────── -->
+  <aside class="cp-aside">
+    <div class="mx-widget">
+      <div class="mx-widget-head">
+        <span class="mx-online-dot" style="position:static;margin-right:4px"></span>
+        Online Now
+        <span class="mx-widget-count"><?= count($onlineUsers) ?></span>
+      </div>
+      <?php if (empty($onlineUsers)): ?>
+        <div class="mx-widget-empty">No one online right now</div>
+      <?php else: ?>
+        <p style="padding:10px 14px 4px;font-size:12px;color:var(--muted)">Click to message them directly</p>
+        <div class="mx-online-list">
+          <?php foreach ($onlineUsers as $ou): ?>
+            <button class="mx-online-row cp-pick" type="button"
+                    onclick="pickUser('<?= e($ou['username']) ?>')"
+                    title="Send to @<?= e($ou['username']) ?>">
+              <div style="position:relative;flex-shrink:0">
+                <?php if ($ou['avatar']): ?>
+                  <img src="<?= e($ou['avatar']) ?>" class="av-sm" alt="">
+                <?php else: ?>
+                  <span class="av-sm av-init"><?= strtoupper($ou['username'][0]) ?></span>
+                <?php endif; ?>
+                <span class="mx-online-dot mx-online-dot-sm"></span>
+              </div>
+              <div class="mx-online-info">
+                <span class="mx-online-name">@<?= e($ou['username']) ?></span>
+                <span class="role-tag role-<?= e($ou['role']) ?>"><?= e($ou['role']) ?></span>
+              </div>
+              <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--faint)" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
+            </button>
+          <?php endforeach; ?>
+        </div>
+      <?php endif; ?>
+    </div>
+
+    <!-- Search any user -->
+    <div class="mx-widget" style="margin-top:14px">
+      <div class="mx-widget-head">🔍 Find User</div>
+      <div style="padding:10px">
+        <input type="text" id="findUser" class="fi" placeholder="Search username…" autocomplete="off">
+        <div id="findResults" class="to-suggestions" style="position:static;margin-top:6px;border-radius:var(--r)"></div>
+      </div>
+    </div>
+  </aside>
+</div>
+
+<script>
+/* ── To-field autocomplete ──────────────────── */
+var toInp = document.getElementById('toInput');
+var toSug = document.getElementById('toSuggestions');
+var toTmr;
+if (toInp) {
+  toInp.addEventListener('input', function() {
+    clearTimeout(toTmr);
+    var q = this.value.trim().replace(/^@/, '');
+    if (!q) { toSug.style.display = 'none'; return; }
+    toTmr = setTimeout(function() {
+      fetch(NX.base + '/api/search_users.php?q=' + encodeURIComponent(q))
+        .then(function(r) { return r.json(); })
+        .then(function(rows) {
+          if (!rows.length) { toSug.style.display = 'none'; return; }
+          toSug.innerHTML = rows.map(function(u) {
+            return '<div class="to-sug-item" onclick="pickUser(\'' + u.username + '\')">' +
+              '@' + u.username + '</div>';
+          }).join('');
+          toSug.style.display = 'block';
+        });
+    }, 220);
+  });
+  document.addEventListener('click', function(e) {
+    if (!toInp.contains(e.target)) toSug.style.display = 'none';
+  });
+}
+
+/* ── Find user panel ────────────────────────── */
+var findInp = document.getElementById('findUser');
+var findRes = document.getElementById('findResults');
+var findTmr;
+if (findInp) {
+  findInp.addEventListener('input', function() {
+    clearTimeout(findTmr);
+    var q = this.value.trim();
+    if (!q) { findRes.style.display = 'none'; return; }
+    findTmr = setTimeout(function() {
+      fetch(NX.base + '/api/search_users.php?q=' + encodeURIComponent(q))
+        .then(function(r) { return r.json(); })
+        .then(function(rows) {
+          if (!rows.length) { findRes.style.display = 'none'; return; }
+          findRes.innerHTML = rows.map(function(u) {
+            return '<div class="to-sug-item" onclick="pickUser(\'' + u.username + '\')">' +
+              '@' + u.username + '</div>';
+          }).join('');
+          findRes.style.display = 'block';
+        });
+    }, 220);
+  });
+}
+
+/* ── Pick user ──────────────────────────────── */
+function pickUser(uname) {
+  document.getElementById('toInput').value = uname;
+  toSug.style.display = 'none';
+  if (findRes) findRes.style.display = 'none';
+  document.getElementById('toInput').focus();
+  document.getElementById('toInput').dispatchEvent(new Event('input'));
+}
+
+/* ── Char counter ───────────────────────────── */
+var body = document.getElementById('msgBody');
+var cnt  = document.getElementById('bodyCount');
+if (body && cnt) {
+  body.addEventListener('input', function() {
+    var n = this.value.length;
+    cnt.textContent = n + ' / 5000';
+    cnt.style.color = n > 4500 ? '#ef4444' : '';
+  });
+}
+</script>
+
+<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>
diff --git a/messages/index.php b/messages/index.php
new file mode 100644
index 0000000..68bda61
--- /dev/null
+++ b/messages/index.php
@@ -0,0 +1,242 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_login();
+
+$box = get('box', 'inbox');
+if (!in_array($box, ['inbox', 'sent'])) $box = 'inbox';
+
+/* ── Fetch messages ─────────────────────────────────── */
+if ($box === 'inbox') {
+    $msgs = DB::rows("
+        SELECT m.*, u.username AS other_name, u.avatar AS other_av,
+               u.last_seen, u.role
+        FROM messages m
+        JOIN users u ON u.id = m.sender_id
+        WHERE m.receiver_id = ? AND m.deleted_by_receiver = 0
+        ORDER BY m.created_at DESC
+    ", [$USER['id']]);
+} else {
+    $msgs = DB::rows("
+        SELECT m.*, u.username AS other_name, u.avatar AS other_av,
+               u.last_seen, u.role
+        FROM messages m
+        JOIN users u ON u.id = m.receiver_id
+        WHERE m.sender_id = ? AND m.deleted_by_sender = 0
+        ORDER BY m.created_at DESC
+    ", [$USER['id']]);
+}
+
+/* ── Online users (last seen within 15 min) ─────────── */
+$since    = DB::sinceSeconds(900);
+$onlineUsers = DB::rows(
+    "SELECT id, username, avatar, role FROM users
+     WHERE last_seen >= $since AND id != ? AND suspended = 0
+     ORDER BY last_seen DESC LIMIT 20",
+    [$USER['id']]
+);
+
+$unread    = unread_messages();
+$sentCount = (int)DB::val("SELECT COUNT(*) FROM messages WHERE sender_id=? AND deleted_by_sender=0", [$USER['id']]);
+
+$PAGE_TITLE = 'Messages';
+include __DIR__ . '/../views/partials/layout.php';
+?>
+
+<div class="mx-layout">
+
+  <!-- ── Left: Inbox ───────────────────────────── -->
+  <div class="mx-main">
+
+    <div class="mx-topbar">
+      <div>
+        <h1 class="mx-title">
+          <?= $box === 'inbox' ? '📥 Inbox' : '📤 Sent' ?>
+          <?php if ($box === 'inbox' && $unread > 0): ?>
+            <span class="mx-unread-badge"><?= $unread ?> unread</span>
+          <?php endif; ?>
+        </h1>
+      </div>
+      <a href="<?= u('messages/compose.php') ?>" class="btn-primary">
+        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
+        Compose
+      </a>
+    </div>
+
+    <!-- Tabs -->
+    <div class="mx-tabs">
+      <a href="?box=inbox" class="mx-tab <?= $box === 'inbox' ? 'active' : '' ?>">
+        📥 Inbox
+        <?php if ($unread > 0): ?>
+          <span class="mx-tab-badge"><?= $unread ?></span>
+        <?php endif; ?>
+      </a>
+      <a href="?box=sent" class="mx-tab <?= $box === 'sent' ? 'active' : '' ?>">
+        📤 Sent
+        <?php if ($sentCount > 0): ?>
+          <span class="mx-tab-badge muted"><?= $sentCount ?></span>
+        <?php endif; ?>
+      </a>
+    </div>
+
+    <?php if (isset($_GET['sent'])): ?>
+      <div class="alert ok" style="margin-bottom:16px">✓ Message sent successfully.</div>
+    <?php endif; ?>
+
+    <?php if (empty($msgs)): ?>
+      <div class="mx-empty">
+        <div class="mx-empty-icon"><?= $box === 'inbox' ? '📭' : '📤' ?></div>
+        <p><?= $box === 'inbox' ? 'Your inbox is empty.' : 'No sent messages yet.' ?></p>
+        <a href="<?= u('messages/compose.php') ?>" class="btn-primary">Send a message</a>
+      </div>
+    <?php else: ?>
+      <div class="mx-list">
+        <?php foreach ($msgs as $m):
+          $isUnread = !$m['is_read'] && $box === 'inbox';
+          $isOnline = strtotime($m['last_seen']) >= (time() - 900);
+          $name     = $m['other_name'];
+          $av       = $m['other_av'];
+        ?>
+          <a href="<?= u('messages/view.php?id=' . $m['id']) ?>"
+             class="mx-row <?= $isUnread ? 'unread' : '' ?>">
+
+            <!-- Avatar + online dot -->
+            <div class="mx-row-av">
+              <?php if ($av): ?>
+                <img src="<?= e($av) ?>" class="av-md" alt="">
+              <?php else: ?>
+                <span class="av-md av-init"><?= strtoupper($name[0]) ?></span>
+              <?php endif; ?>
+              <?php if ($isOnline): ?>
+                <span class="mx-online-dot" title="Online now"></span>
+              <?php endif; ?>
+            </div>
+
+            <!-- Content -->
+            <div class="mx-row-body">
+              <div class="mx-row-top">
+                <span class="mx-row-from">
+                  <?= $box === 'inbox' ? 'From' : 'To' ?>
+                  <strong>@<?= e($name) ?></strong>
+                  <span class="role-tag role-<?= e($m['role']) ?>"><?= e($m['role']) ?></span>
+                  <?php if ($isOnline): ?>
+                    <span class="mx-online-label">● Online</span>
+                  <?php endif; ?>
+                </span>
+                <span class="mx-row-time">
+                  <?php if ($isUnread): ?>
+                    <span class="mx-new-badge">NEW</span>
+                  <?php endif; ?>
+                  <span class="ago" data-ts="<?= e($m['created_at']) ?>"></span>
+                </span>
+              </div>
+              <?php if ($m['subject']): ?>
+                <div class="mx-row-subject"><?= e($m['subject']) ?></div>
+              <?php endif; ?>
+              <div class="mx-row-preview"><?= e(mb_substr($m['body'], 0, 100)) ?><?= mb_strlen($m['body']) > 100 ? '…' : '' ?></div>
+            </div>
+
+            <!-- Arrow -->
+            <div class="mx-row-arrow">›</div>
+          </a>
+        <?php endforeach; ?>
+      </div>
+    <?php endif; ?>
+  </div>
+
+  <!-- ── Right: Online users + quick compose ───── -->
+  <aside class="mx-aside">
+
+    <!-- Online users -->
+    <div class="mx-widget">
+      <div class="mx-widget-head">
+        <span class="mx-online-dot" style="position:static;margin-right:4px"></span>
+        Online Now
+        <span class="mx-widget-count"><?= count($onlineUsers) ?></span>
+      </div>
+      <?php if (empty($onlineUsers)): ?>
+        <div class="mx-widget-empty">No one online right now</div>
+      <?php else: ?>
+        <div class="mx-online-list">
+          <?php foreach ($onlineUsers as $ou): ?>
+            <div class="mx-online-row">
+              <div style="position:relative;flex-shrink:0">
+                <?php if ($ou['avatar']): ?>
+                  <img src="<?= e($ou['avatar']) ?>" class="av-sm" alt="">
+                <?php else: ?>
+                  <span class="av-sm av-init"><?= strtoupper($ou['username'][0]) ?></span>
+                <?php endif; ?>
+                <span class="mx-online-dot mx-online-dot-sm"></span>
+              </div>
+              <div class="mx-online-info">
+                <a href="<?= u('users/profile.php?u=' . urlencode($ou['username'])) ?>"
+                   class="mx-online-name">@<?= e($ou['username']) ?></a>
+                <span class="role-tag role-<?= e($ou['role']) ?>"><?= e($ou['role']) ?></span>
+              </div>
+              <a href="<?= u('messages/compose.php?to=' . urlencode($ou['username'])) ?>"
+                 class="mx-msg-quick" title="Send message">
+                <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
+              </a>
+            </div>
+          <?php endforeach; ?>
+        </div>
+      <?php endif; ?>
+    </div>
+
+    <!-- Quick compose -->
+    <div class="mx-widget" style="margin-top:16px">
+      <div class="mx-widget-head">✏️ Quick Message</div>
+      <div style="padding:14px">
+        <div style="position:relative;margin-bottom:10px">
+          <input type="text" id="quickTo" class="fi" placeholder="@username" autocomplete="off"
+                 style="padding-left:26px">
+          <span style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--faint);font-size:13px;pointer-events:none">@</span>
+          <div id="quickSuggestions" class="to-suggestions"></div>
+        </div>
+        <a id="quickComposeBtn" href="<?= u('messages/compose.php') ?>" class="btn-primary btn-block">
+          Compose →
+        </a>
+      </div>
+    </div>
+
+  </aside>
+</div>
+
+<script>
+/* Quick compose — update link as user types */
+var qTo  = document.getElementById('quickTo');
+var qBtn = document.getElementById('quickComposeBtn');
+var qSug = document.getElementById('quickSuggestions');
+var qTimer;
+
+if (qTo) {
+  qTo.addEventListener('input', function() {
+    var val = this.value.trim().replace(/^@/, '');
+    qBtn.href = NX.base + '/messages/compose.php' + (val ? '?to=' + encodeURIComponent(val) : '');
+    clearTimeout(qTimer);
+    if (val.length < 1) { qSug.style.display = 'none'; return; }
+    qTimer = setTimeout(function() {
+      fetch(NX.base + '/api/search_users.php?q=' + encodeURIComponent(val))
+        .then(function(r) { return r.json(); })
+        .then(function(rows) {
+          if (!rows.length) { qSug.style.display = 'none'; return; }
+          qSug.innerHTML = rows.slice(0, 5).map(function(u) {
+            return '<div class="to-sug-item" onclick="pickQuick(\'' + u.username + '\')">@' + u.username + '</div>';
+          }).join('');
+          qSug.style.display = 'block';
+        });
+    }, 220);
+  });
+  document.addEventListener('click', function(e) {
+    if (!qTo.contains(e.target)) qSug.style.display = 'none';
+  });
+}
+
+function pickQuick(uname) {
+  qTo.value = uname;
+  qBtn.href = NX.base + '/messages/compose.php?to=' + encodeURIComponent(uname);
+  qSug.style.display = 'none';
+  qBtn.click();
+}
+</script>
+
+<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>
diff --git a/messages/view.php b/messages/view.php
new file mode 100644
index 0000000..e2f82ba
--- /dev/null
+++ b/messages/view.php
@@ -0,0 +1,245 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_login();
+
+$id  = (int)get('id');
+$msg = DB::row("
+    SELECT m.*,
+           s.username AS sname, s.avatar AS sav, s.role AS srole,
+           s.karma AS skarma, s.last_seen AS s_last_seen,
+           r.username AS rname, r.avatar AS rav, r.role AS rrole,
+           r.last_seen AS r_last_seen
+    FROM messages m
+    JOIN users s ON s.id = m.sender_id
+    JOIN users r ON r.id = m.receiver_id
+    WHERE m.id = ?
+", [$id]);
+
+if (!$msg)                                                           render_404();
+if ($msg['sender_id'] !== $USER['id'] && $msg['receiver_id'] !== $USER['id']) render_403();
+if ($msg['receiver_id'] === $USER['id'] && $msg['deleted_by_receiver'])       render_404();
+if ($msg['sender_id'] === $USER['id'] && $msg['deleted_by_sender'])           render_404();
+
+// Mark as read
+if ($msg['receiver_id'] === $USER['id'] && !$msg['is_read']) {
+    DB::run('UPDATE messages SET `is_read`=1 WHERE id=?', [$id]);
+}
+
+// Determine the "other" person
+$otherId   = $msg['sender_id'] === $USER['id'] ? $msg['receiver_id'] : $msg['sender_id'];
+$otherName = $msg['sender_id'] === $USER['id'] ? $msg['rname']       : $msg['sname'];
+$otherAv   = $msg['sender_id'] === $USER['id'] ? $msg['rav']         : $msg['sav'];
+$otherRole = $msg['sender_id'] === $USER['id'] ? $msg['rrole']       : $msg['srole'];
+$otherSeen = $msg['sender_id'] === $USER['id'] ? $msg['r_last_seen'] : $msg['s_last_seen'];
+$otherOnline = strtotime($otherSeen) >= (time() - 900);
+
+// Conversation thread (all messages between these two users)
+function conv_id_local(int $a, int $b): string { return min($a,$b).'-'.max($a,$b); }
+$convId  = conv_id_local((int)$USER['id'], $otherId);
+$thread  = DB::rows("
+    SELECT m.*, u.username AS sender_name, u.avatar AS sender_av
+    FROM messages m JOIN users u ON u.id = m.sender_id
+    WHERE (m.sender_id=? AND m.receiver_id=?)
+       OR (m.sender_id=? AND m.receiver_id=?)
+    ORDER BY m.created_at ASC
+", [$USER['id'], $otherId, $otherId, $USER['id']]);
+
+// Handle POST actions
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && csrf_ok()) {
+    $action = post('action');
+
+    if ($action === 'delete_msg') {
+        $delId = (int)post('del_id');
+        $dm    = DB::row('SELECT * FROM messages WHERE id=?', [$delId]);
+        if ($dm && ($dm['sender_id'] === $USER['id'] || $dm['receiver_id'] === $USER['id'])) {
+            if ($dm['sender_id']   === $USER['id']) DB::run('UPDATE messages SET deleted_by_sender=1 WHERE id=?',   [$delId]);
+            if ($dm['receiver_id'] === $USER['id']) DB::run('UPDATE messages SET deleted_by_receiver=1 WHERE id=?', [$delId]);
+        }
+        go('messages/view.php?id=' . $id);
+    }
+
+    if ($action === 'delete') {
+        if ($msg['receiver_id'] === $USER['id'])
+            DB::run('UPDATE messages SET deleted_by_receiver=1 WHERE id=?', [$id]);
+        else
+            DB::run('UPDATE messages SET deleted_by_sender=1 WHERE id=?', [$id]);
+        go('messages/');
+    }
+
+    if ($action === 'reply') {
+        $body = sanitise(post('body'));
+        if ($body) {
+            $subject = $msg['subject'] ? ('Re: ' . ltrim($msg['subject'], 'Re: ')) : '';
+            DB::insert(
+                'INSERT INTO messages (sender_id,receiver_id,subject,body) VALUES (?,?,?,?)',
+                [$USER['id'], $otherId, $subject, $body]
+            );
+            add_notification($otherId, 'message', [
+                'from'    => $USER['username'],
+                'from_id' => $USER['id'],
+                'subject' => mb_substr($body, 0, 60) . (mb_strlen($body) > 60 ? '…' : ''),
+            ]);
+            go('messages/view.php?id=' . $id . '&replied=1');
+        }
+    }
+}
+
+$PAGE_TITLE = $msg['subject'] ?: 'Conversation with @' . $otherName;
+include __DIR__ . '/../views/partials/layout.php';
+?>
+
+<div class="mv-layout">
+
+  <!-- ── Thread sidebar ───────────────────────── -->
+  <aside class="mv-aside">
+    <!-- Other person card -->
+    <div class="mv-user-card">
+      <div class="mv-user-av">
+        <?php if ($otherAv): ?>
+          <img src="<?= e($otherAv) ?>" class="av-lg" alt="">
+        <?php else: ?>
+          <span class="av-lg av-init"><?= strtoupper($otherName[0]) ?></span>
+        <?php endif; ?>
+        <?php if ($otherOnline): ?>
+          <span class="mv-online-dot" title="Online now"></span>
+        <?php endif; ?>
+      </div>
+      <div class="mv-user-info">
+        <a href="<?= u('users/profile.php?u=' . urlencode($otherName)) ?>" class="mv-user-name">
+          @<?= e($otherName) ?>
+        </a>
+        <span class="role-tag role-<?= e($otherRole) ?>"><?= e($otherRole) ?></span>
+        <div class="mv-user-status <?= $otherOnline ? 'online' : 'offline' ?>">
+          <span class="mv-status-dot"></span>
+          <?= $otherOnline ? 'Online now' : 'Last seen <span class="ago" data-ts="' . e($otherSeen) . '"></span>' ?>
+        </div>
+      </div>
+      <div class="mv-user-actions">
+        <a href="<?= u('users/profile.php?u=' . urlencode($otherName)) ?>" class="btn-ghost btn-sm">Profile</a>
+        <a href="<?= u('messages/compose.php?to=' . urlencode($otherName)) ?>" class="btn-ghost btn-sm">New Message</a>
+      </div>
+    </div>
+
+    <!-- Conversation stats -->
+    <div class="mv-conv-stats">
+      <div class="mv-cs-item">
+        <span class="mv-cs-num"><?= count($thread) ?></span>
+        <span class="mv-cs-lbl">messages</span>
+      </div>
+      <div class="mv-cs-item">
+        <span class="mv-cs-num"><?= count(array_filter($thread, fn($m) => !$m['is_read'] && $m['sender_id'] == $otherId)) ?></span>
+        <span class="mv-cs-lbl">unread</span>
+      </div>
+    </div>
+
+    <a href="<?= u('messages/') ?>" class="btn-ghost btn-block" style="margin-top:4px">← Back to Inbox</a>
+  </aside>
+
+  <!-- ── Main thread ───────────────────────────── -->
+  <div class="mv-main">
+
+    <!-- Thread header -->
+    <div class="mv-thread-head">
+      <h1 class="mv-thread-title">
+        <?= $msg['subject'] ? e($msg['subject']) : 'Conversation with @' . e($otherName) ?>
+      </h1>
+      <form method="POST" style="display:inline-block">
+        <?= csrf_input() ?><input type="hidden" name="action" value="delete">
+        <button class="btn-ghost btn-sm" style="color:var(--red)"
+                onclick="return confirm('Delete this message thread?')">🗑 Delete</button>
+      </form>
+    </div>
+
+    <?php if (isset($_GET['replied'])): ?>
+      <div class="alert ok" style="margin-bottom:16px">✓ Reply sent.</div>
+    <?php endif; ?>
+
+    <!-- Message thread -->
+    <div class="mv-thread">
+      <?php foreach ($thread as $tm):
+        $isMine  = ((int)$tm['sender_id'] === (int)$USER['id']);
+        $canDel  = ($tm['sender_id'] == $USER['id'] && !$tm['deleted_by_sender'])
+                || ($tm['receiver_id'] == $USER['id'] && !$tm['deleted_by_receiver']);
+        $isHighlight = ((int)$tm['id'] === $id && !$isMine);
+      ?>
+        <div class="mv-msg <?= $isMine ? 'mv-mine' : 'mv-theirs' ?> <?= $isHighlight ? 'mv-highlight' : '' ?>"
+             id="msg-<?= $tm['id'] ?>">
+
+          <!-- Avatar -->
+          <div class="mv-msg-av" <?= $isMine ? 'style="order:3"' : '' ?>>
+            <?php if ($tm['sender_av']): ?>
+              <img src="<?= e($tm['sender_av']) ?>" class="av-sm" alt="">
+            <?php else: ?>
+              <span class="av-sm av-init"><?= strtoupper($tm['sender_name'][0]) ?></span>
+            <?php endif; ?>
+          </div>
+
+          <!-- Bubble -->
+          <div class="mv-msg-bubble <?= $isMine ? 'mv-bubble-mine' : 'mv-bubble-theirs' ?>">
+            <div class="mv-msg-body"><?= nl2br(e($tm['body'])) ?></div>
+            <div class="mv-msg-meta">
+              <span class="ago" data-ts="<?= e($tm['created_at']) ?>"></span>
+              <?php if ($isMine && $tm['is_read']): ?>
+                <span class="mv-read-tick" title="Read">✓✓</span>
+              <?php elseif ($isMine): ?>
+                <span class="mv-sent-tick" title="Sent">✓</span>
+              <?php endif; ?>
+              <?php if ($canDel): ?>
+                <form method="POST" style="display:inline">
+                  <?= csrf_input() ?>
+                  <input type="hidden" name="action" value="delete_msg">
+                  <input type="hidden" name="del_id" value="<?= $tm['id'] ?>">
+                  <button class="mv-del-btn" title="Delete" onclick="return confirm('Delete this message?')">✕</button>
+                </form>
+              <?php endif; ?>
+            </div>
+          </div>
+        </div>
+      <?php endforeach; ?>
+    </div>
+
+    <!-- Reply box -->
+    <div class="mv-reply-box" id="replyBox">
+      <div class="mv-reply-head">
+        <span>↩ Reply to @<?= e($otherName) ?></span>
+        <?php if ($otherOnline): ?>
+          <span style="color:var(--green);font-size:12px">● Online — will see it right away</span>
+        <?php endif; ?>
+      </div>
+      <form method="POST" id="replyForm">
+        <?= csrf_input() ?>
+        <input type="hidden" name="action" value="reply">
+        <textarea name="body" id="replyBody" class="mv-reply-ta" rows="4" required
+                  maxlength="5000" placeholder="Write your reply…"></textarea>
+        <div class="mv-reply-footer">
+          <span class="mv-char-cnt" id="charCnt">0 / 5000</span>
+          <button type="submit" class="btn-primary">Send Reply</button>
+        </div>
+      </form>
+    </div>
+
+  </div>
+</div>
+
+<script>
+// Scroll to highlighted message
+var hl = document.querySelector('.mv-highlight');
+if (hl) hl.scrollIntoView({behavior:'smooth', block:'center'});
+
+// Scroll thread to bottom if viewing latest
+var thread = document.querySelector('.mv-thread');
+if (thread && !hl) thread.scrollTop = thread.scrollHeight;
+
+// Char counter
+var ta = document.getElementById('replyBody');
+var cc = document.getElementById('charCnt');
+if (ta && cc) {
+  ta.addEventListener('input', function() {
+    var n = this.value.length;
+    cc.textContent = n + ' / 5000';
+    cc.style.color = n > 4500 ? '#ef4444' : '';
+  });
+}
+</script>
+
+<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>
diff --git a/public/css/admin.css b/public/css/admin.css
new file mode 100644
index 0000000..786f217
--- /dev/null
+++ b/public/css/admin.css
@@ -0,0 +1,61 @@
+/* ── Admin Layout ────────────────────────────────────────── */
+.admin-body{background:#f1f5f9;margin:0;padding:0;font-family:var(--font)}
+.admin-wrap{display:flex;min-height:100vh}
+.admin-sb{width:230px;background:#0f172a;flex-shrink:0;position:fixed;top:0;left:0;height:100vh;overflow-y:auto;z-index:50}
+.admin-sb-top{padding:18px 16px;border-bottom:1px solid rgba(255,255,255,.07)}
+.admin-logo{display:flex;align-items:center;gap:10px;text-decoration:none}
+.admin-logo:hover{text-decoration:none}
+.admin-logo .logo-mark{width:36px;height:36px;font-size:18px;border-radius:9px}
+.admin-logo-name{color:#fff;font-size:14px;font-weight:600}
+.admin-logo-sub{color:rgba(255,255,255,.35);font-size:11px}
+.admin-nav{padding:10px 8px}
+.anav{display:flex;align-items:center;gap:9px;padding:9px 11px;border-radius:7px;font-size:14px;font-weight:500;color:rgba(255,255,255,.5);text-decoration:none;transition:all .18s;margin-bottom:2px}
+.anav:hover{background:rgba(255,255,255,.07);color:#fff;text-decoration:none}
+.anav.on{background:var(--blue);color:#fff}
+.anav-div{height:1px;background:rgba(255,255,255,.07);margin:8px 10px}
+.admin-main{flex:1;margin-left:230px;display:flex;flex-direction:column}
+.admin-topbar{background:#fff;border-bottom:1px solid #e2e8f0;padding:15px 26px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:40;box-shadow:0 1px 3px rgba(0,0,0,.06)}
+.admin-topbar h1{font-size:17px;font-weight:700;color:#0f172a;margin:0}
+.admin-topbar span{font-size:13px;color:#64748b}
+.admin-content{padding:22px 26px}
+
+/* ── Stat cards ──────────────────────────────────────────── */
+.stat-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:20px}
+@media(max-width:1100px){.stat-grid{grid-template-columns:repeat(2,1fr)}}
+.stat-card{background:#fff;border:1px solid #e2e8f0;border-radius:11px;padding:18px;display:flex;align-items:flex-start;gap:14px;box-shadow:0 1px 3px rgba(0,0,0,.06);transition:all .18s}
+.stat-card:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.1)}
+.stat-icon{width:46px;height:46px;border-radius:11px;display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
+.stat-num{font-size:1.7rem;font-weight:700;color:#0f172a;line-height:1}
+.stat-label{font-size:13px;color:#64748b;margin-top:3px}
+.stat-sub{font-size:12px;color:#22c55e;margin-top:2px}
+
+/* ── Admin Cards ─────────────────────────────────────────── */
+.acard{background:#fff;border:1px solid #e2e8f0;border-radius:11px;overflow:hidden;margin-bottom:18px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
+.acard-head{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid #f8fafc}
+.acard-head h2{font-size:14px;font-weight:600;color:#0f172a;margin:0}
+.acard-body{padding:18px}
+
+.admin-two-col{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:18px}
+@media(max-width:900px){.admin-two-col{grid-template-columns:1fr}}
+
+/* ── Tables ──────────────────────────────────────────────── */
+.atable{width:100%;border-collapse:collapse;font-size:13px}
+.atable thead tr{background:#f8fafc;border-bottom:1px solid #e2e8f0}
+.atable th{padding:9px 15px;text-align:left;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#94a3b8;white-space:nowrap}
+.atable td{padding:11px 15px;border-bottom:1px solid #f8fafc;color:#0f172a;vertical-align:middle}
+.atable tr:last-child td{border-bottom:none}
+.atable tbody tr:hover{background:#fafafa}
+.atable a{color:var(--blue);text-decoration:none;font-weight:500}
+.atable a:hover{text-decoration:underline}
+
+/* ── Status / role badges ────────────────────────────────── */
+.stag{display:inline-flex;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
+.stag.active{background:#dcfce7;color:#166534}
+.stag.suspended{background:#fee2e2;color:#991b1b}
+.stag.silenced{background:#fef3c7;color:#92400e}
+
+/* ── Admin toolbar ───────────────────────────────────────── */
+.admin-toolbar{margin-bottom:18px}
+.at-form{display:flex;gap:10px;align-items:center}
+.at-fi{flex:1;max-width:380px;padding:8px 13px;border:1px solid #e2e8f0;border-radius:var(--r);font-size:14px;font-family:var(--font);outline:none;transition:border-color .18s}
+.at-fi:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(59,130,246,.1)}
diff --git a/public/css/main.css b/public/css/main.css
new file mode 100644
index 0000000..58b12e8
--- /dev/null
+++ b/public/css/main.css
@@ -0,0 +1,1165 @@
+/* ═══════════════════════════════════════════════════════════════
+   NEXUS FORUM — main.css
+   Single canonical stylesheet. No duplicates.
+   ═══════════════════════════════════════════════════════════════ */
+
+/* ── Variables ─────────────────────────────────────────────────── */
+:root {
+  --blue:#3b82f6; --blue-d:#2563eb; --blue-l:#eff6ff;
+  --green:#22c55e; --red:#ef4444; --amber:#f59e0b; --purple:#8b5cf6;
+  --bg:#f1f5f9; --surface:#fff; --border:#e2e8f0; --border-l:#f1f5f9;
+  --text:#0f172a; --muted:#64748b; --faint:#94a3b8;
+  --header:56px; --sidebar:210px;
+  --r:8px; --r-lg:12px;
+  --sh:0 1px 3px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.05);
+  --sh-md:0 4px 12px rgba(0,0,0,.1);
+  --sh-lg:0 10px 30px rgba(0,0,0,.12);
+  --font:'Inter',-apple-system,sans-serif;
+  --mono:'JetBrains Mono','Fira Code',monospace;
+}
+
+/* ── Reset ─────────────────────────────────────────────────────── */
+*,*::before,*::after { box-sizing:border-box; margin:0; padding:0; }
+html { font-size:15px; scroll-behavior:smooth; }
+body { font-family:var(--font); background:var(--bg); color:var(--text); line-height:1.6; -webkit-font-smoothing:antialiased; min-height:100vh; display:flex; flex-direction:column; }
+a { color:var(--blue); text-decoration:none; }
+a:hover { text-decoration:underline; }
+img { max-width:100%; height:auto; }
+
+/* ── Header ─────────────────────────────────────────────────────── */
+.site-header { position:fixed; top:0; left:0; right:0; height:var(--header); background:var(--surface); border-bottom:1px solid var(--border); z-index:100; box-shadow:var(--sh); }
+.hdr-inner { display:flex; align-items:center; gap:12px; height:100%; padding:0 16px; max-width:1440px; margin:0 auto; }
+.hdr-left { display:flex; align-items:center; gap:10px; flex-shrink:0; }
+.hdr-search { flex:1; max-width:460px; }
+.hdr-right { display:flex; align-items:center; gap:8px; margin-left:auto; flex-shrink:0; }
+
+/* Burger */
+.burger { width:34px; height:34px; background:none; border:none; cursor:pointer; display:flex; flex-direction:column; justify-content:center; gap:5px; padding:4px; border-radius:6px; transition:background .18s; }
+.burger:hover { background:var(--border-l); }
+.burger span { display:block; height:2px; background:var(--muted); border-radius:2px; transition:all .25s; }
+.burger.open span:nth-child(1) { transform:translateY(7px) rotate(45deg); }
+.burger.open span:nth-child(2) { opacity:0; }
+.burger.open span:nth-child(3) { transform:translateY(-7px) rotate(-45deg); }
+
+/* Logo */
+.logo { display:flex; align-items:center; gap:9px; color:var(--text); text-decoration:none; }
+.logo:hover { text-decoration:none; }
+.logo-mark { width:32px; height:32px; background:var(--blue); color:#fff; border-radius:8px; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:16px; flex-shrink:0; }
+.logo-name { font-size:16px; font-weight:600; white-space:nowrap; }
+.logo-img { height:32px; width:auto; object-fit:contain; }
+
+/* Search */
+.search-wrap { position:relative; width:100%; }
+.srch-icon { width:14px; height:14px; position:absolute; left:10px; top:50%; transform:translateY(-50%); color:var(--faint); pointer-events:none; }
+#searchInput { width:100%; height:36px; border:1px solid var(--border); border-radius:18px; background:var(--bg); padding:0 12px 0 30px; font-size:14px; font-family:var(--font); outline:none; transition:all .18s; }
+#searchInput:focus { background:var(--surface); border-color:var(--blue); box-shadow:0 0 0 3px rgba(59,130,246,.15); }
+.search-results { position:absolute; top:calc(100% + 6px); left:0; right:0; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); box-shadow:var(--sh-md); z-index:200; display:none; overflow:hidden; }
+.search-results.show { display:block; }
+.sr-item { display:block; padding:10px 14px; font-size:14px; color:var(--text); text-decoration:none; border-bottom:1px solid var(--border-l); transition:background .18s; }
+.sr-item:last-child { border-bottom:none; }
+.sr-item:hover { background:var(--bg); text-decoration:none; }
+.sr-item small { display:block; font-size:12px; color:var(--muted); margin-top:1px; }
+
+/* ── Sidebar ─────────────────────────────────────────────────────── */
+.sb-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,.4); z-index:90; backdrop-filter:blur(2px); }
+.sb-overlay.show { display:block; }
+.sidebar { position:fixed; top:var(--header); left:0; width:var(--sidebar); height:calc(100vh - var(--header)); background:var(--surface); border-right:1px solid var(--border); overflow-y:auto; z-index:95; transition:transform .25s; scrollbar-width:thin; }
+@media(max-width:900px) { .sidebar { transform:translateX(-100%); } .sidebar.open { transform:translateX(0); } }
+.sidebar nav { padding:10px 8px; }
+.nav-link { display:flex; align-items:center; gap:9px; padding:8px 10px; border-radius:7px; font-size:14px; font-weight:500; color:var(--muted); text-decoration:none; transition:all .18s; margin-bottom:2px; }
+.nav-link:hover { background:var(--blue-l); color:var(--blue); text-decoration:none; }
+.new-link { background:var(--blue)!important; color:#fff!important; margin-bottom:8px; }
+.new-link:hover { background:var(--blue-d)!important; }
+.nav-sep { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.06em; color:var(--faint); padding:10px 10px 4px; }
+.cat-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
+
+/* ── Main layout ─────────────────────────────────────────────────── */
+.main { margin-top:var(--header); margin-left:var(--sidebar); flex:1; }
+@media(max-width:900px) { .main { margin-left:0; } .sidebar { transform:translateX(-100%); } }
+.wrap { max-width:1100px; margin:0 auto; padding:28px 24px; }
+@media(max-width:600px) { .wrap { padding:16px; } }
+
+/* ── Footer ─────────────────────────────────────────────────────── */
+.site-footer { margin-left:var(--sidebar); background:var(--surface); border-top:1px solid var(--border); padding:14px 24px; font-size:13px; color:var(--muted); }
+@media(max-width:900px) { .site-footer { margin-left:0; } }
+.footer-inner { max-width:1100px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
+.footer-stats { display:flex; align-items:center; gap:10px; font-size:13px; color:var(--muted); flex-wrap:wrap; }
+.footer-stats strong { color:var(--text); }
+.fs-div { color:var(--faint); }
+.footer-right { display:flex; align-items:center; gap:14px; font-size:13px; color:var(--muted); }
+
+/* ── Dropdowns ───────────────────────────────────────────────────── */
+.notif-wrap,.user-wrap { position:relative; }
+.icon-btn { width:36px; height:36px; display:flex; align-items:center; justify-content:center; background:none; border:none; border-radius:50%; cursor:pointer; color:var(--muted); position:relative; transition:all .18s; }
+.icon-btn:hover { background:var(--border-l); color:var(--text); }
+.badge-dot { position:absolute; top:1px; right:1px; background:var(--red); color:#fff; font-size:9px; font-weight:700; border-radius:10px; padding:1px 4px; min-width:16px; text-align:center; line-height:14px; }
+.avatar-btn { width:36px; height:36px; border-radius:50%; border:2px solid var(--border); background:none; cursor:pointer; padding:0; overflow:hidden; transition:border-color .18s; }
+.avatar-btn:hover { border-color:var(--blue); }
+.notif-drop,.user-drop { position:absolute; right:0; top:calc(100% + 8px); background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); box-shadow:var(--sh-lg); z-index:200; display:none; min-width:200px; animation:dropIn .18s ease; }
+.notif-drop.show,.user-drop.show { display:block; }
+.notif-drop { min-width:300px; right:-60px; }
+@keyframes dropIn { from{opacity:0;transform:translateY(-8px)} to{opacity:1;transform:translateY(0)} }
+.notif-head { display:flex; justify-content:space-between; align-items:center; padding:12px 16px; border-bottom:1px solid var(--border-l); font-weight:600; font-size:14px; }
+.link-btn { background:none; border:none; color:var(--blue); font-size:12px; cursor:pointer; padding:2px 6px; font-family:var(--font); }
+.link-btn:hover { text-decoration:underline; }
+#notifList { max-height:320px; overflow-y:auto; }
+.notif-item { display:block; padding:11px 16px; border-bottom:1px solid var(--border-l); font-size:13px; color:var(--text); text-decoration:none; transition:background .18s; }
+.notif-item:hover { background:var(--bg); }
+.notif-item.unread { background:var(--blue-l); }
+.notif-item-time { font-size:11px; color:var(--faint); margin-top:2px; }
+.notif-empty { padding:20px; text-align:center; color:var(--muted); font-size:13px; }
+.user-drop-top { padding:12px 16px 8px; border-bottom:1px solid var(--border-l); display:flex; align-items:center; gap:8px; }
+.user-drop a { display:flex; align-items:center; padding:9px 16px; font-size:14px; color:var(--text); text-decoration:none; transition:background .18s; }
+.user-drop a:hover { background:var(--bg); }
+.admin-lnk { color:var(--blue)!important; }
+.logout-lnk { color:var(--red)!important; }
+.drop-div { height:1px; background:var(--border-l); margin:4px 0; }
+
+/* ── Buttons ─────────────────────────────────────────────────────── */
+.btn-primary { display:inline-flex; align-items:center; gap:6px; padding:8px 18px; background:var(--blue); color:#fff; border:none; border-radius:var(--r); font-size:14px; font-weight:500; font-family:var(--font); cursor:pointer; text-decoration:none; transition:all .18s; white-space:nowrap; }
+.btn-primary:hover { background:var(--blue-d); color:#fff; text-decoration:none; transform:translateY(-1px); }
+.btn-ghost { display:inline-flex; align-items:center; gap:6px; padding:7px 16px; background:transparent; color:var(--muted); border:1px solid var(--border); border-radius:var(--r); font-size:14px; font-weight:500; font-family:var(--font); cursor:pointer; text-decoration:none; transition:all .18s; }
+.btn-ghost:hover { border-color:var(--blue); color:var(--blue); background:var(--blue-l); text-decoration:none; }
+.btn-lg { padding:11px 24px; font-size:15px; }
+.btn-sm { padding:5px 12px; font-size:13px; }
+.btn-block { width:100%; justify-content:center; }
+.btn-warn { background:var(--amber); color:#fff; border:none; border-radius:var(--r); cursor:pointer; padding:5px 12px; font-size:13px; font-family:var(--font); }
+.btn-ok { background:var(--green); color:#fff; border:none; border-radius:var(--r); cursor:pointer; padding:5px 12px; font-size:13px; font-family:var(--font); }
+.btn-danger { background:var(--red); color:#fff; border:none; border-radius:var(--r); cursor:pointer; padding:5px 12px; font-size:13px; font-family:var(--font); }
+
+/* ── Forms ───────────────────────────────────────────────────────── */
+.fg { margin-bottom:16px; }
+.fg label { display:block; font-size:14px; font-weight:500; margin-bottom:5px; }
+.fi { width:100%; padding:9px 12px; border:1px solid var(--border); border-radius:var(--r); font-size:14px; font-family:var(--font); color:var(--text); background:var(--surface); outline:none; transition:all .18s; }
+.fi:focus { border-color:var(--blue); box-shadow:0 0 0 3px rgba(59,130,246,.12); }
+textarea.fi { resize:vertical; min-height:80px; }
+select.fi { cursor:pointer; }
+.req { color:var(--red); }
+.hint { font-size:12px; color:var(--faint); margin-top:3px; display:block; }
+.form-actions { display:flex; gap:12px; margin-top:20px; flex-wrap:wrap; }
+.form-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:28px; box-shadow:var(--sh); max-width:760px; }
+.form-card h1 { font-size:1.4rem; font-weight:700; margin-bottom:20px; }
+.form-section { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:20px; margin-bottom:18px; box-shadow:var(--sh); }
+.form-section h2 { font-size:15px; font-weight:600; margin-bottom:14px; }
+.pw-row { position:relative; }
+.pw-eye { position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; cursor:pointer; font-size:16px; }
+.pw-bar { height:4px; background:var(--border); border-radius:2px; margin-top:6px; overflow:hidden; }
+.pw-fill { height:100%; width:0; border-radius:2px; transition:all .3s; }
+.tag-preview { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; min-height:22px; }
+
+/* ── Alerts ─────────────────────────────────────────────────────── */
+.alert { padding:12px 16px; border-radius:var(--r); font-size:14px; margin-bottom:18px; }
+.alert.err,.alert-err { background:#fef2f2; border:1px solid #fecaca; color:#991b1b; }
+.alert.ok  { background:#f0fdf4; border:1px solid #bbf7d0; color:#166534; }
+.alert.warn{ background:#fffbeb; border:1px solid #fde68a; color:#92400e; }
+
+/* ── Avatars ─────────────────────────────────────────────────────── */
+.av-sm  { width:34px; height:34px; border-radius:50%; object-fit:cover; flex-shrink:0; }
+.av-md  { width:42px; height:42px; border-radius:50%; object-fit:cover; flex-shrink:0; }
+.av-lg  { width:54px; height:54px; border-radius:50%; object-fit:cover; flex-shrink:0; }
+.av-xl  { width:76px; height:76px; border-radius:50%; object-fit:cover; flex-shrink:0; }
+.av-init { background:linear-gradient(135deg,var(--blue) 0%,var(--purple) 100%); color:#fff; font-weight:700; display:inline-flex; align-items:center; justify-content:center; border-radius:50%; }
+.av-sm.av-init  { width:34px; height:34px; font-size:14px; }
+.av-md.av-init  { width:42px; height:42px; font-size:16px; }
+.av-lg.av-init  { width:54px; height:54px; font-size:20px; }
+.av-xl.av-init  { width:76px; height:76px; font-size:28px; }
+.av-xs { width:22px; height:22px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; background:var(--blue); color:#fff; font-size:10px; font-weight:700; }
+
+/* ── Role tags ───────────────────────────────────────────────────── */
+.role-tag { display:inline-flex; padding:1px 7px; border-radius:10px; font-size:11px; font-weight:600; text-transform:capitalize; }
+.role-admin     { background:#fee2e2; color:#991b1b; }
+.role-moderator { background:#dbeafe; color:#1e40af; }
+.role-member    { background:#f1f5f9; color:#64748b; }
+.role-flair     { font-size:9px; font-weight:700; padding:1px 5px; border-radius:4px; text-transform:uppercase; }
+.role-flair.admin { background:var(--red); color:#fff; }
+.role-flair.mod   { background:var(--blue); color:#fff; }
+
+/* ── Tags / pills ────────────────────────────────────────────────── */
+.tag { display:inline-flex; align-items:center; padding:2px 8px; background:#f1f5f9; color:var(--muted); border-radius:10px; font-size:11px; font-weight:500; border:1px solid var(--border); }
+.cat-tag { display:inline-flex; align-items:center; padding:2px 7px; border-radius:10px; font-size:11px; font-weight:500; background:color-mix(in srgb,var(--cc,var(--blue)) 15%,transparent); color:var(--cc,var(--blue)); border:1px solid color-mix(in srgb,var(--cc,var(--blue)) 30%,transparent); text-decoration:none; }
+.cat-tag:hover { text-decoration:none; filter:brightness(.9); }
+
+/* ── Home page ───────────────────────────────────────────────────── */
+.home-hero { background:linear-gradient(135deg,#3b82f6 0%,#1d4ed8 60%,#1e3a8a 100%); border-radius:var(--r-lg); padding:44px 36px; margin-bottom:22px; color:#fff; position:relative; overflow:hidden; }
+.home-hero::before { content:''; position:absolute; inset:0; background:url("data:image/svg+xml,%3Csvg width='60' height='60' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='7' cy='7' r='7' fill='%23fff' fill-opacity='.04'/%3E%3C/svg%3E"); pointer-events:none; }
+.home-hero h1 { font-size:2.2rem; font-weight:700; margin-bottom:8px; position:relative; }
+.home-hero p  { font-size:1.05rem; opacity:.85; margin-bottom:20px; position:relative; }
+.hero-btns { display:flex; gap:12px; flex-wrap:wrap; position:relative; }
+.hero-btns .btn-primary { background:#fff; color:var(--blue); }
+.hero-btns .btn-primary:hover { background:rgba(255,255,255,.9); }
+.hero-btns .btn-ghost { color:#fff; border-color:rgba(255,255,255,.5); }
+.hero-btns .btn-ghost:hover { background:rgba(255,255,255,.15); border-color:#fff; color:#fff; }
+.stats-row { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); display:flex; align-items:center; padding:14px 20px; margin-bottom:22px; gap:4px; flex-wrap:wrap; box-shadow:var(--sh); }
+.stat { display:flex; align-items:center; gap:8px; padding:2px 12px; font-size:13px; color:var(--muted); }
+.stat strong { font-size:1.25rem; font-weight:700; color:var(--text); }
+.stat-div { width:1px; height:28px; background:var(--border); }
+.home-grid { display:grid; grid-template-columns:3fr 2fr; gap:22px; }
+@media(max-width:860px) { .home-grid { grid-template-columns:1fr; } }
+.sec-title { display:flex; align-items:center; gap:8px; font-size:14px; font-weight:600; margin-bottom:14px; padding-bottom:10px; border-bottom:2px solid var(--border-l); }
+
+/* ── Category cards ──────────────────────────────────────────────── */
+.cat-card { display:flex; align-items:center; gap:12px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:15px 16px; text-decoration:none; color:var(--text); transition:all .18s; position:relative; overflow:hidden; box-shadow:var(--sh); margin-bottom:8px; }
+.cat-card:hover { border-color:var(--blue); transform:translateY(-1px); box-shadow:var(--sh-md); text-decoration:none; color:var(--text); }
+.cat-stripe { position:absolute; left:0; top:0; bottom:0; width:4px; }
+.cat-icon { font-size:22px; flex-shrink:0; }
+.cat-body { flex:1; min-width:0; }
+.cat-name  { font-weight:600; font-size:14px; margin-bottom:2px; }
+.cat-desc  { font-size:12px; color:var(--muted); margin-bottom:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.cat-stats { font-size:11px; color:var(--faint); }
+.cat-chevron { font-size:18px; color:var(--faint); flex-shrink:0; }
+.empty-msg { color:var(--muted); font-size:14px; padding:20px 0; }
+
+/* ── Topic rows ──────────────────────────────────────────────────── */
+.topic-row { display:flex; align-items:flex-start; gap:10px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); padding:12px 14px; margin-bottom:6px; transition:all .18s; box-shadow:var(--sh); }
+.topic-row:hover { border-color:var(--blue); transform:translateY(-1px); box-shadow:var(--sh-md); }
+.tr-body   { flex:1; min-width:0; }
+.tr-title  { font-weight:500; font-size:14px; color:var(--text); display:block; margin-bottom:4px; }
+.tr-title:hover { color:var(--blue); text-decoration:none; }
+.tr-meta   { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); flex-wrap:wrap; }
+.tr-counts { display:flex; flex-direction:column; align-items:flex-end; gap:2px; font-size:12px; color:var(--faint); flex-shrink:0; }
+
+/* ── Category page ───────────────────────────────────────────────── */
+.bc { display:flex; align-items:center; gap:6px; font-size:13px; color:var(--muted); margin-bottom:16px; flex-wrap:wrap; }
+.bc a { color:var(--muted); } .bc a:hover { color:var(--blue); }
+.cat-hdr { display:flex; align-items:center; gap:16px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:22px 24px; margin-bottom:18px; box-shadow:var(--sh); flex-wrap:wrap; }
+.cat-hdr-icon { font-size:2.2rem; }
+.cat-hdr h1 { font-size:1.4rem; font-weight:700; }
+.cat-hdr p  { color:var(--muted); margin-top:3px; font-size:14px; }
+.cat-hdr-meta { font-size:12px; color:var(--faint); margin-top:6px; }
+.subcats { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px; }
+.subcat { display:inline-flex; align-items:center; gap:6px; padding:5px 13px; background:var(--surface); border:1px solid; border-radius:18px; font-size:13px; font-weight:500; text-decoration:none; color:var(--text); transition:all .18s; }
+.subcat:hover { transform:translateY(-1px); text-decoration:none; }
+.topic-list { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); overflow:hidden; box-shadow:var(--sh); }
+.tl-hdr { display:grid; grid-template-columns:1fr 80px 70px 100px; padding:10px 18px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; color:var(--faint); border-bottom:1px solid var(--border); background:var(--bg); }
+.tl-row { display:grid; grid-template-columns:1fr 80px 70px 100px; padding:13px 18px; border-bottom:1px solid var(--border-l); align-items:center; transition:background .18s; }
+.tl-row:last-child { border-bottom:none; }
+.tl-row:hover { background:var(--bg); }
+.tl-row.is-pinned { background:#fffbeb; }
+.tl-main { display:flex; align-items:flex-start; gap:10px; min-width:0; }
+.tl-title { display:flex; align-items:center; gap:5px; flex-wrap:wrap; margin-bottom:3px; }
+.tl-title a { font-weight:500; font-size:14px; color:var(--text); }
+.tl-title a:hover { color:var(--blue); text-decoration:none; }
+.tl-meta { font-size:12px; color:var(--muted); }
+.tl-r { text-align:right; font-size:13px; color:var(--muted); }
+.empty-state { padding:44px 24px; text-align:center; }
+.empty-state p { color:var(--muted); margin-bottom:14px; }
+
+/* ── Topic page ──────────────────────────────────────────────────── */
+.topic-hdr { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:22px 24px; margin-bottom:20px; display:flex; align-items:flex-start; gap:16px; box-shadow:var(--sh); flex-wrap:wrap; }
+.topic-hdr-main { flex:1; }
+.topic-badges { display:flex; gap:6px; margin-bottom:8px; flex-wrap:wrap; }
+.tbadge { display:inline-flex; align-items:center; padding:3px 10px; border-radius:10px; font-size:12px; font-weight:600; }
+.tbadge.pin    { background:#fef3c7; color:#92400e; }
+.tbadge.closed { background:#fee2e2; color:#991b1b; }
+.topic-title { font-size:1.5rem; font-weight:700; margin-bottom:10px; letter-spacing:-.02em; line-height:1.3; }
+.topic-meta  { display:flex; flex-wrap:wrap; align-items:center; gap:8px; font-size:13px; color:var(--muted); }
+.topic-mod   { display:flex; gap:6px; flex-wrap:wrap; align-items:flex-start; flex-shrink:0; }
+
+/* ── Posts ───────────────────────────────────────────────────────── */
+.post { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); display:flex; overflow:hidden; margin-bottom:6px; box-shadow:var(--sh); transition:border-color .18s; }
+.post:hover { border-color:#c7d3e4; }
+.post-op { border-left:3px solid var(--blue)!important; }
+.post-side { width:86px; flex-shrink:0; padding:18px 10px; background:var(--bg); border-right:1px solid var(--border-l); display:flex; flex-direction:column; align-items:center; gap:5px; text-align:center; }
+.post-name { font-size:11px; font-weight:600; color:var(--text); text-decoration:none; word-break:break-all; }
+.post-name:hover { color:var(--blue); }
+.post-pcnt  { font-size:10px; color:var(--faint); }
+.post-location { display:flex; align-items:center; justify-content:center; gap:2px; font-size:10px; color:var(--faint); margin-top:1px; max-width:80px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.post-karma { font-size:11px; color:var(--faint); margin-top:2px; }
+.post-karma-badge { display:inline-flex; align-items:center; gap:3px; margin-top:4px; padding:3px 7px; background:rgba(34,197,94,.12); border:1px solid rgba(34,197,94,.3); border-radius:10px; font-size:11px; font-weight:700; color:#16a34a; }
+.pkb-icon { font-size:10px; display:inline-flex; align-items:center; }
+.pkb-val  { font-family:var(--mono); letter-spacing:.01em; }
+.post-body { flex:1; min-width:0; padding:15px 18px; }
+.post-meta-bar { display:flex; align-items:center; gap:8px; margin-bottom:10px; padding-bottom:10px; border-bottom:1px solid var(--border-l); flex-wrap:wrap; }
+.pnum { font-size:12px; color:var(--faint); font-weight:600; }
+.edit-lbl { font-size:12px; color:var(--faint); font-style:italic; }
+.post-acts { display:flex; align-items:center; gap:4px; margin-left:auto; flex-wrap:wrap; }
+.pa-btn { display:inline-flex; align-items:center; gap:4px; padding:3px 9px; background:none; border:1px solid var(--border); border-radius:18px; font-size:12px; cursor:pointer; color:var(--muted); transition:all .18s; font-family:var(--font); }
+.pa-btn:hover { border-color:var(--blue); color:var(--blue); background:var(--blue-l); }
+.pa-btn.liked { color:var(--red)!important; border-color:var(--red)!important; background:#fef2f2!important; }
+.pa-btn.del:hover { color:var(--red)!important; border-color:var(--red)!important; background:#fef2f2!important; }
+.reply-ref { font-size:12px; color:var(--muted); background:var(--bg); padding:4px 10px; border-radius:6px; margin-bottom:10px; border-left:2px solid var(--blue); display:inline-flex; align-items:center; gap:5px; }
+
+/* ── Post content (rendered markdown) ────────────────────────────── */
+.post-content,.rendered-post { line-height:1.75; font-size:15px; word-wrap:break-word; overflow-wrap:break-word; }
+.rendered-post h1,.rendered-post h2,.rendered-post h3,.rendered-post h4 { font-weight:600; margin:1.1em 0 .5em; line-height:1.3; }
+.rendered-post h1{font-size:1.5em} .rendered-post h2{font-size:1.3em} .rendered-post h3{font-size:1.1em}
+.rendered-post p,.post-content p { margin-bottom:.9em; }
+.rendered-post p:last-child,.post-content p:last-child { margin-bottom:0; }
+.rendered-post ul,.rendered-post ol,.post-content ul,.post-content ol { padding-left:1.5em; margin-bottom:.9em; }
+.rendered-post li,.post-content li { margin-bottom:.25em; }
+/* ── Blockquote ── dark-green circle + light-green background ── */
+.rendered-post blockquote,
+.post-content blockquote,
+blockquote.post-quote {
+  position: relative;
+  margin: 12px 0;
+  padding: 12px 16px 12px 44px;  /* left room for the circle */
+  background: #f0fdf4;            /* very light green */
+  border: 1px solid #bbf7d0;
+  border-radius: var(--r-lg);
+  color: #166534;                 /* dark green text */
+  font-style: normal;
+}
+/* Dark green filled circle on the left */
+.rendered-post blockquote::before,
+.post-content blockquote::before,
+blockquote.post-quote::before {
+  content: '';
+  position: absolute;
+  left: 14px;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 10px;
+  height: 10px;
+  background: #16a34a;            /* dark green circle */
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+.rendered-post blockquote p,
+.post-content blockquote p,
+blockquote.post-quote p {
+  margin: 0 0 4px;
+  color: #166534;
+}
+.rendered-post blockquote p:last-child,
+.post-content blockquote p:last-child,
+blockquote.post-quote p:last-child { margin-bottom: 0; }
+.rendered-post blockquote strong,
+.post-content blockquote strong { color: #14532d; }
+.rendered-post blockquote em,
+.post-content blockquote em { color: #166534; }
+[data-theme="dark"] .rendered-post blockquote,
+[data-theme="dark"] .post-content blockquote,
+[data-theme="dark"] blockquote.post-quote {
+  background: rgba(22,163,74,.08);
+  border-color: rgba(22,163,74,.25);
+  color: #86efac;
+}
+[data-theme="dark"] .rendered-post blockquote::before,
+[data-theme="dark"] .post-content blockquote::before,
+[data-theme="dark"] blockquote.post-quote::before {
+  background: #4ade80;
+}
+[data-theme="dark"] .rendered-post blockquote p,
+[data-theme="dark"] .post-content blockquote p,
+[data-theme="dark"] blockquote.post-quote p { color: #86efac; }
+
+/* ── Inline code ─────────────────────────────────────────────── */
+.inline-code,
+.rendered-post :not(pre) > code,
+.post-content :not(pre) > code {
+  background: #f1f0ff;
+  color: #7c3aed;
+  border: 1px solid #ddd6fe;
+  padding: 1px 6px;
+  border-radius: 5px;
+  font-family: var(--mono);
+  font-size: .88em;
+  font-style: normal;
+  white-space: nowrap;
+  font-weight: 500;
+}
+
+/* ── Code block wrapper ──────────────────────────────────────── */
+.rendered-post pre,
+/* ─────────────────────────────────────────────────────────────────
+   CODE BLOCKS
+   Wrapper: .code-block-wrap  Header: .cb-header  Block: pre.code-block
+   ─────────────────────────────────────────────────────────────────── */
+
+/* ── Code block ── purple circle + light-purple background ─────── */
+.code-block-wrap {
+  position: relative;
+  margin: 12px 0;
+  padding: 0;                     /* inner padding handled by code element */
+  background: #faf5ff;            /* very light purple */
+  border: 1px solid #ddd6fe;
+  border-radius: var(--r-lg);
+  overflow: hidden;
+}
+/* Purple filled circle — same circle concept as blockquote */
+.code-block-wrap::before {
+  content: '';
+  position: absolute;
+  left: 14px;
+  top: 14px;
+  width: 10px;
+  height: 10px;
+  background: #7c3aed;            /* dark purple circle */
+  border-radius: 50%;
+  z-index: 1;
+  pointer-events: none;
+}
+
+/* Header bar — language label + copy button */
+.cb-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  background: #f3e8ff;            /* slightly deeper purple tint */
+  border-bottom: 1px solid #ddd6fe;
+  padding: 6px 10px 6px 34px;    /* 34px left to clear the circle */
+  gap: 8px;
+  user-select: none;
+  min-height: 34px;
+}
+/* No-lang header: circle still shows, just copy button on right */
+.cb-header-nolang {
+  justify-content: flex-end;
+  padding: 5px 10px 5px 34px;
+  min-height: 30px;
+}
+
+/* Language label */
+.cb-lang {
+  font-family: var(--mono);
+  font-size: 11px;
+  font-weight: 700;
+  color: #6d28d9;
+  text-transform: uppercase;
+  letter-spacing: .08em;
+}
+
+/* Copy button */
+.cb-copy {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  padding: 3px 9px;
+  background: transparent;
+  border: 1px solid #c4b5fd;
+  border-radius: 5px;
+  font-size: 11px;
+  font-family: var(--font);
+  font-weight: 500;
+  color: #7c3aed;
+  cursor: pointer;
+  transition: all .15s;
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+.cb-copy:hover {
+  background: #ede9fe;
+  border-color: #a78bfa;
+  color: #6d28d9;
+}
+.cb-copy.copied {
+  border-color: #16a34a;
+  color: #16a34a;
+  background: #f0fdf4;
+}
+.cb-copy svg { flex-shrink: 0; }
+
+/* The <pre> block — light purple background, dark purple text */
+pre.code-block {
+  background: #faf5ff;
+  color: #3b1c6e;
+  margin: 0;
+  padding: 0;
+  overflow: hidden;
+  font-family: var(--mono);
+  font-size: 13.5px;
+  line-height: 1.7;
+  tab-size: 4;
+  -moz-tab-size: 4;
+}
+
+/* Scrollable code area — preserves ALL whitespace exactly */
+pre.code-block code {
+  display: block;
+  padding: 14px 16px 14px 34px;  /* 34px left = past the circle */
+  overflow-x: auto;
+  background: transparent;
+  color: inherit;
+  font-size: inherit;
+  font-family: inherit;
+  line-height: inherit;
+  white-space: pre;
+  word-break: normal;
+  overflow-wrap: normal;
+  -webkit-overflow-scrolling: touch;
+  scrollbar-width: thin;
+  scrollbar-color: #c4b5fd transparent;
+}
+pre.code-block code::-webkit-scrollbar       { height: 5px; }
+pre.code-block code::-webkit-scrollbar-track { background: transparent; }
+pre.code-block code::-webkit-scrollbar-thumb { background: #c4b5fd; border-radius: 3px; }
+
+/* Inline code */
+.inline-code,
+.post-content :not(pre) > code,
+.rendered-post :not(pre) > code {
+  font-family: var(--mono);
+  font-size: .88em;
+  background: #f5f3ff;
+  color: #7c3aed;
+  border: 1px solid #ddd6fe;
+  border-radius: 4px;
+  padding: 1px 6px;
+  white-space: nowrap;
+  font-weight: 500;
+}
+
+/* Dark mode */
+[data-theme="dark"] .code-block-wrap {
+  background: #1e1334;
+  border-color: #3d2a6b;
+}
+[data-theme="dark"] .code-block-wrap::before {
+  background: #a78bfa;
+}
+[data-theme="dark"] .cb-header {
+  background: #2a1a4e;
+  border-color: #3d2a6b;
+}
+[data-theme="dark"] .cb-header-nolang {
+  background: #2a1a4e;
+}
+[data-theme="dark"] .cb-lang { color: #c4b5fd; }
+[data-theme="dark"] .cb-copy {
+  border-color: #4c3a8a;
+  color: #c4b5fd;
+}
+[data-theme="dark"] .cb-copy:hover {
+  background: rgba(167,139,250,.1);
+  border-color: #7c3aed;
+  color: #ddd6fe;
+}
+[data-theme="dark"] pre.code-block {
+  background: #1e1334;
+  color: #e9d5ff;
+}
+[data-theme="dark"] pre.code-block code {
+  scrollbar-color: #4c3a8a transparent;
+}
+[data-theme="dark"] .inline-code,
+[data-theme="dark"] .post-content :not(pre) > code,
+[data-theme="dark"] .rendered-post :not(pre) > code {
+  background: #2d1f5e;
+  border-color: #4c3a8a;
+  color: #c4b5fd;
+}
+
+
+/* ── Syntax token colors (GitHub Dark palette) ───────────────── */
+/* These work standalone AND override Prism's injected theme      */
+
+/* Comments */
+pre.code-block /* Prism.js token colors — tuned for light purple background */
+pre.code-block .token.comment,
+pre.code-block .token.prolog,
+pre.code-block .token.cdata          { color: #6b7280; font-style: italic; }
+
+pre.code-block .token.keyword,
+pre.code-block .token.rule,
+pre.code-block .token.atrule         { color: #7c3aed; font-weight: 600; }
+
+pre.code-block .token.string,
+pre.code-block .token.attr-value,
+pre.code-block .token.char           { color: #0369a1; }
+
+pre.code-block .token.number,
+pre.code-block .token.boolean,
+pre.code-block .token.constant       { color: #b45309; }
+
+pre.code-block .token.function,
+pre.code-block .token.function-name  { color: #6d28d9; font-weight: 600; }
+
+pre.code-block .token.class-name,
+pre.code-block .token.namespace      { color: #0f766e; }
+
+pre.code-block .token.tag,
+pre.code-block .token.selector       { color: #9333ea; }
+
+pre.code-block .token.attr-name      { color: #7c3aed; }
+
+pre.code-block .token.punctuation    { color: #6b7280; }
+
+pre.code-block .token.operator       { color: #7c3aed; }
+
+pre.code-block .token.variable,
+pre.code-block .token.parameter      { color: #92400e; }
+
+pre.code-block .token.builtin        { color: #0369a1; }
+
+/* Dark mode token colors — dark purple background */
+[data-theme="dark"] pre.code-block .token.comment,
+[data-theme="dark"] pre.code-block .token.prolog    { color: #9ca3af; font-style: italic; }
+[data-theme="dark"] pre.code-block .token.keyword   { color: #c084fc; font-weight: 600; }
+[data-theme="dark"] pre.code-block .token.string    { color: #93c5fd; }
+[data-theme="dark"] pre.code-block .token.number    { color: #fcd34d; }
+[data-theme="dark"] pre.code-block .token.function  { color: #d8b4fe; font-weight: 600; }
+[data-theme="dark"] pre.code-block .token.class-name { color: #6ee7b7; }
+[data-theme="dark"] pre.code-block .token.tag       { color: #f0abfc; }
+[data-theme="dark"] pre.code-block .token.operator  { color: #c084fc; }
+[data-theme="dark"] pre.code-block .token.punctuation { color: #9ca3af; }
+/* ── Edit box ────────────────────────────────────────────────────── */
+.edit-box { display:none; margin-top:14px; border-top:1px solid var(--border-l); padding-top:14px; }
+.edit-box.visible { display:block; }
+.edit-ta { width:100%; min-height:120px; padding:10px 13px; border:1px solid var(--border); border-radius:var(--r); font-family:var(--mono); font-size:13px; resize:vertical; outline:none; transition:border-color .18s; }
+.edit-ta:focus { border-color:var(--blue); box-shadow:0 0 0 3px rgba(59,130,246,.1); }
+.edit-btns { display:flex; gap:8px; margin-top:10px; justify-content:flex-end; }
+
+/* ═══════════════════════════════════════════════════════════════
+   POST EDITOR  (v19 reply-box design)
+   ═══════════════════════════════════════════════════════════════ */
+
+.reply-box { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); overflow:hidden; box-shadow:var(--sh-md); margin-top:22px; }
+.reply-hdr { display:flex; align-items:center; gap:12px; padding:12px 18px; border-bottom:1px solid var(--border-l); background:var(--bg); font-size:14px; color:var(--muted); }
+.ed-toolbar { display:flex; align-items:center; gap:2px; padding:8px 12px; background:var(--bg); border-bottom:1px solid var(--border-l); flex-wrap:wrap; }
+.ed-toolbar button { width:30px; height:30px; display:flex; align-items:center; justify-content:center; background:none; border:none; border-radius:6px; font-size:13px; cursor:pointer; color:var(--muted); transition:all .18s; font-family:var(--font); }
+.ed-toolbar button:hover { background:var(--surface); color:var(--text); }
+.ed-toolbar button#prevBtn.on { background:var(--blue-l); color:var(--blue); }
+.ed-sep { width:1px; height:20px; background:var(--border); margin:0 4px; }
+.ed-panes { position:relative; }
+.reply-ta { width:100%; min-height:130px; padding:15px 18px; border:none; outline:none; font-family:var(--mono); font-size:14px; line-height:1.7; resize:vertical; color:var(--text); background:var(--surface); }
+.reply-ta.dragging { background:var(--blue-l); }
+.reply-preview { min-height:130px; padding:15px 18px; font-size:15px; line-height:1.75; }
+.reply-footer { display:flex; align-items:center; justify-content:space-between; padding:10px 16px; border-top:1px solid var(--border-l); background:var(--bg); flex-wrap:wrap; gap:8px; }
+.reply-footer-right { display:flex; align-items:center; gap:10px; }
+.cnt { font-size:12px; color:var(--faint); }
+.hidden { display:none!important; }
+.reply-cta { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:28px; text-align:center; margin-top:22px; display:flex; align-items:center; justify-content:center; gap:12px; flex-wrap:wrap; color:var(--muted); box-shadow:var(--sh); }
+.reply-cta p { width:100%; margin:0 0 8px; font-weight:500; color:var(--text); }
+.closed-notice { background:#fffbeb; border:1px solid #fde68a; border-radius:var(--r); padding:14px 18px; text-align:center; color:#92400e; margin-top:22px; font-size:14px; font-weight:500; }
+
+
+/* ═══════════════════════════════════════════════════════════════
+   HORIZONTAL POST LAYOUT
+   ═══════════════════════════════════════════════════════════════ */
+.posts-horizontal .post {
+  flex-direction: column;
+}
+.posts-horizontal .post-side {
+  width: 100%;
+  flex-direction: row;
+  border-right: none;
+  border-bottom: 1px solid var(--border-l);
+  padding: 10px 16px;
+  gap: 10px;
+  justify-content: flex-start;
+  align-items: center;
+  background: var(--bg);
+}
+.posts-horizontal .post-side .av-lg {
+  width: 32px; height: 32px;
+  flex-shrink: 0;
+}
+.posts-horizontal .post-name {
+  font-size: 13px;
+}
+.posts-horizontal .post-pcnt,
+.posts-horizontal .post-location {
+  font-size: 11px;
+  white-space: nowrap;
+}
+.posts-horizontal .post-karma-badge {
+  margin-top: 0;
+}
+.posts-horizontal .post-side .role-flair {
+  margin: 0;
+}
+.posts-horizontal .post-body {
+  padding: 14px 18px;
+}
+
+/* ── Rate limit / Post captcha ───────────────────────────────────── */
+.rate-limit-info { margin-bottom:10px; }
+.rate-limit-bar { display:flex; align-items:center; gap:12px; background:#fffbeb; border:1px solid #fde68a; border-radius:var(--r); padding:10px 14px; font-size:13px; color:#92400e; }
+.rate-countdown { display:inline-flex; align-items:center; justify-content:center; background:#f59e0b; color:#fff; font-weight:700; font-size:15px; border-radius:50%; width:38px; height:38px; flex-shrink:0; font-family:var(--mono); }
+.post-captcha-row { display:flex; align-items:center; gap:12px; background:#f0fdf4; border:1px solid #bbf7d0; border-radius:var(--r); padding:10px 14px; margin:8px 16px; flex-wrap:wrap; }
+.post-captcha-label { font-size:13px; font-weight:500; color:#166534; white-space:nowrap; }
+.post-captcha-label .captcha-q { font-size:16px; font-family:var(--mono); color:#15803d; }
+.post-captcha-inp { max-width:80px!important; font-size:16px!important; text-align:center; font-family:var(--mono)!important; padding:6px 8px!important; }
+
+/* ── Captcha (registration) ──────────────────────────────────────── */
+.captcha-box { background:#f8fafc; border:2px solid var(--blue); border-radius:var(--r); padding:16px 18px; margin-bottom:16px; }
+.captcha-label { font-size:14px; font-weight:500; color:var(--text); margin-bottom:8px; display:block; }
+.captcha-q { font-size:18px; color:var(--blue); font-family:var(--mono); letter-spacing:.06em; }
+.captcha-input { max-width:100px!important; font-size:18px!important; text-align:center; font-family:var(--mono)!important; }
+
+/* ── Media embeds ────────────────────────────────────────────────── */
+.embed-wrap { position:relative; padding-bottom:56.25%; height:0; overflow:hidden; border-radius:var(--r); margin:12px 0; background:#000; box-shadow:var(--sh-md); }
+.embed-wrap iframe,.embed-yt { position:absolute; top:0; left:0; width:100%; height:100%; border:none; border-radius:var(--r); }
+.embed-wrap[style*="height"] { padding-bottom:0; }
+.embed-wrap[style*="height"] iframe { position:absolute; }
+.embed-spotify { margin:12px 0; border-radius:var(--r); overflow:hidden; box-shadow:var(--sh); }
+.embed-spotify iframe { display:block; border:none; border-radius:var(--r); }
+.embed-sc { margin:12px 0; }
+.embed-sc iframe { border-radius:var(--r); border:none; display:block; }
+.embed-tweet { margin:12px 0; min-height:50px; }
+.tweet-fallback { display:inline-flex; align-items:center; gap:8px; padding:10px 16px; background:#1d9bf0; color:#fff; border-radius:var(--r); font-size:14px; font-weight:500; text-decoration:none; transition:background .18s; }
+.tweet-fallback:hover { background:#1a8cd8; text-decoration:none; color:#fff; }
+
+/* ── Pagination / breadcrumb ─────────────────────────────────────── */
+.pager { display:flex; align-items:center; justify-content:center; gap:6px; padding:22px 0; flex-wrap:wrap; }
+.pg-btn { display:inline-flex; align-items:center; justify-content:center; min-width:36px; height:36px; padding:0 10px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); font-size:14px; color:var(--muted); text-decoration:none; transition:all .18s; }
+.pg-btn:hover { border-color:var(--blue); color:var(--blue); text-decoration:none; }
+.pg-btn.active { background:var(--blue); border-color:var(--blue); color:#fff; }
+
+/* ── Auth pages ──────────────────────────────────────────────────── */
+.auth-body { background:linear-gradient(135deg,#eff6ff,#dbeafe); margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; flex-direction:column; padding:24px; }
+.auth-page { width:100%; max-width:430px; }
+.auth-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:32px; box-shadow:var(--sh-lg); }
+.auth-logo { display:flex; align-items:center; gap:10px; margin-bottom:22px; justify-content:center; text-decoration:none; color:var(--text); font-size:18px; font-weight:700; }
+.auth-logo:hover { text-decoration:none; }
+.auth-card h1 { font-size:1.4rem; font-weight:700; text-align:center; margin-bottom:4px; }
+.auth-sub  { text-align:center; color:var(--muted); font-size:14px; margin-bottom:22px; }
+.auth-foot { text-align:center; margin-top:18px; font-size:14px; color:var(--muted); }
+.auth-back { text-align:center; margin-top:16px; font-size:13px; }
+.auth-back a { color:var(--muted); }
+.auth-terms { font-size:11px; color:var(--faint); text-align:center; margin-top:10px; }
+
+/* ── Profile page ────────────────────────────────────────────────── */
+.profile-hdr { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:24px; display:flex; align-items:flex-start; gap:18px; margin-bottom:22px; flex-wrap:wrap; box-shadow:var(--sh); }
+.profile-info { flex:1; min-width:200px; }
+.profile-name-row { display:flex; align-items:center; gap:10px; margin-bottom:8px; flex-wrap:wrap; }
+.profile-name-row h1 { font-size:1.4rem; font-weight:700; }
+.profile-bio { color:var(--muted); font-size:14px; margin-bottom:10px; }
+.profile-meta { display:flex; gap:14px; font-size:13px; color:var(--muted); flex-wrap:wrap; }
+.profile-stats { display:flex; gap:18px; flex-shrink:0; }
+.pstat { text-align:center; }
+.pstat strong { display:block; font-size:1.4rem; font-weight:700; }
+.pstat span { font-size:12px; color:var(--muted); }
+.pstat.karma-stat strong { color:var(--kc,#f59e0b); font-size:1.6rem; }
+.profile-actions { display:flex; flex-wrap:wrap; gap:8px; align-self:flex-start; }
+.profile-tabs { display:flex; gap:4px; margin-bottom:14px; border-bottom:2px solid var(--border); }
+.tab-btn { padding:9px 18px; background:none; border:none; font-family:var(--font); font-size:14px; font-weight:500; color:var(--muted); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-2px; transition:all .18s; display:inline-flex; align-items:center; }
+.tab-btn:hover { color:var(--blue); }
+.tab-btn.active { color:var(--blue); border-bottom-color:var(--blue); }
+.tab-count { display:inline-flex; align-items:center; justify-content:center; background:var(--border); color:var(--muted); border-radius:10px; font-size:11px; font-weight:600; padding:1px 7px; margin-left:4px; }
+.tab-btn.active .tab-count { background:var(--blue); color:#fff; }
+.tab-pane.hidden { display:none; }
+.reply-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--r); padding:13px 16px; margin-bottom:8px; }
+.rc-top { display:flex; justify-content:space-between; font-size:12px; color:var(--muted); margin-bottom:8px; padding-bottom:8px; border-bottom:1px solid var(--border-l); }
+.rc-body { font-size:13px; color:var(--muted); max-height:80px; overflow:hidden; }
+.av-upload { display:flex; align-items:center; gap:18px; }
+
+/* ── Karma tier badge (profile avatar) ───────────────────────────── */
+.profile-tier-badge { display:inline-flex; align-items:center; gap:4px; margin-top:8px; padding:4px 10px; background:color-mix(in srgb,var(--tc,#f59e0b) 15%,transparent); border:1px solid color-mix(in srgb,var(--tc,#f59e0b) 40%,transparent); border-radius:20px; font-size:12px; font-weight:600; color:var(--tc,#f59e0b); white-space:nowrap; max-width:100%; }
+.profile-karma-row { margin-top:14px; padding:12px 14px; background:var(--bg); border:1px solid var(--border); border-radius:var(--r); }
+.pkr-head { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
+.pkr-icon { font-size:18px; }
+.pkr-label { font-weight:700; font-size:14px; }
+.pkr-pts { margin-left:auto; font-size:13px; font-weight:600; color:var(--muted); font-family:var(--mono); }
+.pkr-bar { height:8px; background:var(--border); border-radius:4px; overflow:hidden; margin-bottom:4px; }
+.pkr-fill { height:100%; border-radius:4px; transition:width .6s cubic-bezier(.4,0,.2,1); }
+.pkr-next { font-size:11px; color:var(--faint); text-align:right; }
+
+/* ── Friends ──────────────────────────────────────────────────────── */
+.pending-requests { background:#fffbeb; border:1px solid #fde68a; border-radius:var(--r-lg); padding:16px 20px; margin-bottom:20px; }
+.pending-requests h3 { font-size:14px; font-weight:600; margin-bottom:12px; color:#92400e; }
+.pending-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid #fde68a; }
+.pending-row:last-child { border-bottom:none; }
+.pending-row a { font-weight:500; flex:1; }
+.friends-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:12px; padding:4px 0; }
+.friend-card { display:flex; flex-direction:column; align-items:center; gap:8px; padding:16px 12px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); text-decoration:none; color:var(--text); transition:all .18s; text-align:center; box-shadow:var(--sh); }
+.friend-card:hover { border-color:var(--blue); transform:translateY(-2px); box-shadow:var(--sh-md); text-decoration:none; }
+.friend-name  { font-size:13px; font-weight:500; word-break:break-all; }
+.friend-karma { font-size:12px; font-weight:600; font-family:var(--mono); }
+
+/* ── Messages ────────────────────────────────────────────────────── */
+.msg-page { max-width:860px; margin:0 auto; }
+.msg-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; }
+.msg-header h1 { font-size:1.4rem; font-weight:700; margin:0; }
+.msg-tabs { display:flex; gap:4px; margin-bottom:16px; border-bottom:2px solid var(--border); }
+.msg-tabs a.tab-btn { text-decoration:none; display:inline-flex; align-items:center; gap:6px; }
+.msg-list { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); overflow:hidden; box-shadow:var(--sh); }
+.msg-row { display:flex; align-items:flex-start; gap:12px; padding:14px 18px; border-bottom:1px solid var(--border-l); text-decoration:none; color:var(--text); transition:background .18s; }
+.msg-row:last-child { border-bottom:none; }
+.msg-row:hover { background:var(--bg); text-decoration:none; }
+.msg-row.unread { background:var(--blue-l); }
+.msg-body { flex:1; min-width:0; }
+.msg-from    { font-size:12px; color:var(--muted); margin-bottom:3px; display:flex; align-items:center; gap:6px; }
+.msg-subject { font-weight:600; font-size:14px; margin-bottom:3px; }
+.msg-preview { font-size:13px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.msg-time { font-size:12px; color:var(--faint); flex-shrink:0; }
+.unread-dot { color:var(--blue); font-size:10px; }
+.msg-view-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); overflow:hidden; box-shadow:var(--sh); margin-bottom:20px; }
+.msg-view-header { display:flex; align-items:flex-start; gap:14px; padding:20px; border-bottom:1px solid var(--border-l); background:var(--bg); }
+.msg-view-subject { font-size:1.1rem; font-weight:700; margin-bottom:4px; }
+.msg-view-meta { font-size:13px; color:var(--muted); }
+.msg-view-body { padding:22px; font-size:15px; line-height:1.75; white-space:pre-wrap; word-wrap:break-word; }
+.to-input-wrap { position:relative; display:flex; align-items:center; }
+.at-prefix { position:absolute; left:12px; color:var(--muted); font-size:15px; font-weight:500; pointer-events:none; z-index:1; }
+.to-input-wrap .fi { padding-left:28px; }
+.to-suggestions { position:absolute; top:100%; left:0; right:0; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); box-shadow:var(--sh-md); z-index:100; display:none; }
+.to-sug-item { padding:9px 14px; font-size:14px; cursor:pointer; transition:background .18s; }
+.to-sug-item:hover { background:var(--bg); }
+
+/* ── @mentions ───────────────────────────────────────────────────── */
+.mention-tag { display:inline-flex; align-items:center; background:var(--blue-l); color:var(--blue); border-radius:4px; padding:1px 5px; font-weight:600; font-size:.9em; text-decoration:none; transition:background .18s; }
+.mention-tag:hover { background:var(--blue); color:#fff; text-decoration:none; }
+.mention-popup { position:absolute; background:var(--surface); border:1px solid var(--border); border-radius:var(--r); box-shadow:var(--sh-md); z-index:500; min-width:180px; max-height:200px; overflow-y:auto; display:none; }
+.mention-popup.show { display:block; }
+.mention-item { display:flex; align-items:center; gap:8px; padding:8px 12px; font-size:13px; cursor:pointer; transition:background .18s; }
+.mention-item:hover,.mention-item.selected { background:var(--blue-l); color:var(--blue); }
+
+/* ── Error pages ─────────────────────────────────────────────────── */
+.err-pg { text-align:center; padding:60px 24px; }
+.err-code { font-size:6rem; font-weight:800; background:linear-gradient(135deg,var(--blue),var(--purple)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; line-height:1; margin-bottom:14px; }
+.err-pg h1 { font-size:1.7rem; margin-bottom:8px; }
+.err-pg p  { color:var(--muted); margin-bottom:22px; }
+
+/* ── Lightbox ────────────────────────────────────────────────────── */
+.lightbox { position:fixed; inset:0; background:rgba(0,0,0,.92); z-index:9999; display:flex; align-items:center; justify-content:center; cursor:zoom-out; backdrop-filter:blur(4px); animation:fadeIn .2s; }
+.lightbox img { max-width:92vw; max-height:90vh; border-radius:var(--r); box-shadow:0 24px 80px rgba(0,0,0,.8); }
+
+/* ── Toggle switch ───────────────────────────────────────────────── */
+.toggle-sw { position:relative; display:inline-block; width:44px; height:24px; flex-shrink:0; }
+.toggle-sw input { opacity:0; width:0; height:0; }
+.toggle-knob { position:absolute; cursor:pointer; inset:0; background:#cbd5e1; border-radius:24px; transition:.3s; }
+.toggle-knob::before { content:''; position:absolute; width:18px; height:18px; left:3px; bottom:3px; background:#fff; border-radius:50%; transition:.3s; box-shadow:0 1px 3px rgba(0,0,0,.2); }
+.toggle-sw input:checked + .toggle-knob { background:var(--blue); }
+.toggle-sw input:checked + .toggle-knob::before { transform:translateX(20px); }
+.toggle-label { display:flex; align-items:center; justify-content:space-between; padding:8px 0; cursor:pointer; }
+
+/* ── Search page ─────────────────────────────────────────────────── */
+.search-form { display:flex; gap:10px; margin-bottom:20px; }
+.search-fi { flex:1; max-width:600px; }
+
+/* ── Animations ──────────────────────────────────────────────────── */
+@keyframes fadeIn { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} }
+
+/* ── Mobile ──────────────────────────────────────────────────────── */
+@media(max-width:600px) {
+  .home-hero { padding:24px 18px; }
+  .home-hero h1 { font-size:1.5rem; }
+  .topic-title { font-size:1.1rem; }
+  .post-side { width:58px; }
+  .post-pcnt { display:none; }
+  .tl-hdr,.tl-row { grid-template-columns:1fr 60px 60px; }
+  .profile-hdr { flex-direction:column; }
+  .post-captcha-row { flex-direction:column; align-items:flex-start; }
+}
+
+
+
+/* ═══════════════════════════════════════════════════════════════
+   LIVE SEARCH DROPDOWN — topics + posts sections
+   ═══════════════════════════════════════════════════════════════ */
+.sr-section-lbl {
+  padding: 6px 14px 4px;
+  font-size: 10px;
+  font-weight: 700;
+  text-transform: uppercase;
+  letter-spacing: .07em;
+  color: var(--faint);
+  background: var(--bg);
+  border-bottom: 1px solid var(--border-l);
+  border-top: 1px solid var(--border-l);
+}
+.sr-section-lbl:first-child { border-top: none; }
+
+.sr-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 9px 14px;
+  font-size: 14px;
+  color: var(--text);
+  text-decoration: none;
+  border-bottom: 1px solid var(--border-l);
+  transition: background .15s;
+}
+.sr-item:last-child { border-bottom: none; }
+.sr-item:hover { background: var(--bg); text-decoration: none; }
+.sr-item strong { display: block; font-size: 13px; font-weight: 600; margin-bottom: 1px; }
+.sr-item small  { display: block; font-size: 11px; color: var(--muted);
+                  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+.sr-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+.sr-text { flex: 1; min-width: 0; }
+
+.sr-post { background: #fafafa; }
+.sr-post:hover { background: var(--blue-l); }
+
+.sr-goto-badge {
+  flex-shrink: 0;
+  font-size: 11px;
+  font-weight: 700;
+  color: var(--blue);
+  background: var(--blue-l);
+  border: 1px solid #bfdbfe;
+  padding: 2px 7px;
+  border-radius: 8px;
+}
+
+.sr-all {
+  font-size: 13px;
+  color: var(--blue);
+  font-weight: 500;
+  background: var(--bg);
+  justify-content: center;
+  padding: 10px 14px;
+  border-top: 1px solid var(--border-l);
+}
+.sr-all:hover { background: var(--blue-l); }
+
+
+
+
+/* ── Post :target highlight (CSS-only, no JS needed) ────────── */
+.post:target {
+  border-color: var(--blue) !important;
+  box-shadow: 0 0 0 3px rgba(59,130,246,.18), var(--sh) !important;
+  animation: postPing 1.8s ease forwards;
+}
+@keyframes postPing {
+  0%   { box-shadow: 0 0 0 5px rgba(59,130,246,.35), var(--sh); }
+  60%  { box-shadow: 0 0 0 8px rgba(59,130,246,.10), var(--sh); }
+  100% { box-shadow: var(--sh); border-color: var(--border); }
+}
+
+/* ═══════════════════════════════════════════════════════════════
+   DARK MODE  —  [data-theme="dark"] overrides
+   ═══════════════════════════════════════════════════════════════ */
+[data-theme="dark"] {
+  --bg:       #0f1117;
+  --surface:  #1a1d27;
+  --border:   #2d3148;
+  --border-l: #1e2235;
+  --text:     #e2e8f0;
+  --muted:    #94a3b8;
+  --faint:    #64748b;
+  --blue-l:   #1e3a5f;
+  --sh:       0 1px 3px rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3);
+  --sh-md:    0 4px 16px rgba(0,0,0,.4);
+  --sh-lg:    0 10px 40px rgba(0,0,0,.5);
+}
+
+/* Surfaces */
+[data-theme="dark"] body                    { background: var(--bg); }
+[data-theme="dark"] .site-header            { background: #12151e; border-color: var(--border); }
+[data-theme="dark"] .sidebar                { background: #12151e; border-color: var(--border); }
+[data-theme="dark"] .site-footer            { background: #12151e; border-color: var(--border); }
+[data-theme="dark"] .post                   { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .post:hover             { border-color: #3d4464; }
+[data-theme="dark"] .post-side              { background: var(--bg); border-color: var(--border-l); }
+[data-theme="dark"] .posts-horizontal .post-side { background: #12151e; border-color: var(--border); }
+[data-theme="dark"] .form-card              { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .acard                  { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .acard-head             { background: var(--bg); border-color: var(--border); }
+[data-theme="dark"] .acard-body             { background: var(--surface); }
+
+/* Inputs */
+[data-theme="dark"] .fi                     { background: var(--bg); border-color: var(--border); color: var(--text); }
+[data-theme="dark"] .fi:focus               { border-color: var(--blue); background: var(--surface); }
+[data-theme="dark"] select.fi               { background: var(--bg); }
+[data-theme="dark"] .reply-ta               { background: var(--surface); color: var(--text); }
+[data-theme="dark"] .reply-preview          { background: var(--surface); }
+
+/* Navigation */
+[data-theme="dark"] .nav-link:hover         { background: rgba(59,130,246,.15); }
+[data-theme="dark"] .new-link               { background: var(--blue) !important; }
+
+/* Search */
+[data-theme="dark"] #searchInput            { background: #1e2235; border-color: var(--border); color: var(--text); }
+[data-theme="dark"] #searchInput:focus      { background: var(--surface); }
+[data-theme="dark"] .search-results         { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .sr-item:hover          { background: var(--bg); }
+
+/* Topic / forum cards */
+[data-theme="dark"] .topic-row              { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .topic-row:hover        { background: #1e2235; }
+[data-theme="dark"] .cat-card               { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .cat-card:hover         { background: #1e2235; }
+[data-theme="dark"] .tl-row                 { background: var(--surface); }
+[data-theme="dark"] .tl-row:hover           { background: var(--bg); }
+
+/* Buttons */
+[data-theme="dark"] .btn-ghost              { border-color: var(--border); color: var(--muted); }
+[data-theme="dark"] .btn-ghost:hover        { background: var(--border-l); color: var(--text); }
+[data-theme="dark"] .pa-btn                 { border-color: var(--border); color: var(--muted); background: transparent; }
+[data-theme="dark"] .pa-btn:hover           { border-color: var(--blue); color: var(--blue); background: var(--blue-l); }
+[data-theme="dark"] .icon-btn               { color: var(--muted); }
+[data-theme="dark"] .icon-btn:hover         { background: var(--border-l); }
+
+/* Reply box editor */
+[data-theme="dark"] .reply-box              { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .reply-hdr              { background: var(--bg); border-color: var(--border); }
+[data-theme="dark"] .ed-toolbar             { background: var(--bg); border-color: var(--border); }
+[data-theme="dark"] .ed-toolbar button      { color: var(--muted); }
+[data-theme="dark"] .ed-toolbar button:hover { background: var(--surface); color: var(--text); }
+[data-theme="dark"] .reply-footer           { background: var(--bg); border-color: var(--border); }
+
+/* Dropdowns */
+[data-theme="dark"] .notif-drop             { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .notif-item             { border-color: var(--border-l); }
+[data-theme="dark"] .notif-item:hover       { background: var(--bg); }
+[data-theme="dark"] .notif-head             { background: var(--bg); border-color: var(--border); }
+[data-theme="dark"] .user-drop              { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .drop-item:hover        { background: var(--bg); }
+[data-theme="dark"] .drop-div               { background: var(--border); }
+
+/* Code blocks — already dark, just keep them */
+[data-theme="dark"] pre.code-block          { border-color: var(--border); }
+
+/* Inline code */
+[data-theme="dark"] .inline-code,
+[data-theme="dark"] .post-content :not(pre) > code { background: #2d1f5e; border-color: #4c3a8a; color: #c4b5fd; }
+
+/* Blockquote */
+[data-theme="dark"] .post-content blockquote,
+[data-theme="dark"] .rendered-post blockquote { background: #1a2744; border-left-color: var(--blue); color: #94a3b8; }
+
+/* Auth page */
+[data-theme="dark"] .auth-body              { background: linear-gradient(135deg,#0f1117,#1a1d27); }
+[data-theme="dark"] .auth-card              { background: var(--surface); border-color: var(--border); }
+
+/* Profile */
+[data-theme="dark"] .profile-hdr            { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .profile-karma-row      { background: var(--bg); border-color: var(--border); }
+
+/* Messages */
+[data-theme="dark"] .msg-list               { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .msg-row                { color: var(--text); border-color: var(--border-l); }
+[data-theme="dark"] .msg-row:hover          { background: var(--bg); }
+[data-theme="dark"] .msg-row.unread         { background: var(--blue-l); }
+[data-theme="dark"] .msg-view-card          { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .msg-view-header        { background: var(--bg); border-color: var(--border); }
+
+/* mx-* messages */
+[data-theme="dark"] .mx-layout .mx-main,
+[data-theme="dark"] .mx-widget              { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .mx-widget-head         { background: var(--bg); border-color: var(--border); }
+[data-theme="dark"] .mx-row                 { color: var(--text); border-color: var(--border-l); }
+[data-theme="dark"] .mx-row:hover           { background: var(--bg); }
+[data-theme="dark"] .mx-row.unread          { background: var(--blue-l); }
+
+/* Alerts */
+[data-theme="dark"] .alert.ok               { background: #052e16; border-color: #166534; color: #bbf7d0; }
+[data-theme="dark"] .alert.err              { background: #450a0a; border-color: #991b1b; color: #fecaca; }
+[data-theme="dark"] .alert.warn             { background: #431407; border-color: #9a3412; color: #fed7aa; }
+
+/* Admin */
+[data-theme="dark"] .admin-sidebar          { background: #12151e; border-color: var(--border); }
+[data-theme="dark"] .anav                   { color: var(--muted); }
+[data-theme="dark"] .anav:hover,.anav.on    { background: var(--blue-l); color: var(--blue); }
+[data-theme="dark"] .atable tr:hover td     { background: var(--bg); }
+[data-theme="dark"] .atable thead tr        { background: var(--bg); }
+
+/* Misc */
+[data-theme="dark"] .reply-cta              { background: var(--surface); border-color: var(--border); }
+[data-theme="dark"] .closed-notice          { background: #431407; border-color: #9a3412; color: #fed7aa; }
+[data-theme="dark"] .pager .pg-btn          { background: var(--surface); border-color: var(--border); color: var(--muted); }
+[data-theme="dark"] .pg-btn.active          { background: var(--blue); border-color: var(--blue); color: #fff; }
+[data-theme="dark"] .sr-section-lbl         { background: var(--bg); border-color: var(--border); }
+[data-theme="dark"] .sr-item                { background: var(--surface); border-color: var(--border-l); }
+[data-theme="dark"] .sr-post                { background: #1a1d27; }
+[data-theme="dark"] .sr-all                 { background: var(--bg); }
+[data-theme="dark"] .tag                    { background: #1e2235; border-color: var(--border); color: var(--muted); }
+[data-theme="dark"] .tag-preview .tag       { background: #1a2744; border-color: #2d4a7a; color: #93c5fd; }
+[data-theme="dark"] .mention-tag            { background: var(--blue-l); color: #93c5fd; }
+
+/* ── Welcome guide addon CSS ──────────────────────────────────── */
+.wg-guide {
+  margin-top: 14px;
+  border-top: 1px solid var(--border-l);
+  padding-top: 10px;
+}
+.wg-toggle {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  background: none;
+  border: 1px solid var(--border);
+  border-radius: var(--r);
+  padding: 4px 10px;
+  font-size: 12px;
+  font-family: var(--font);
+  color: var(--muted);
+  cursor: pointer;
+  transition: all .15s;
+}
+.wg-toggle:hover { background: var(--bg); color: var(--text); }
+.wg-toggle.wg-open { background: var(--blue-l); color: var(--blue); border-color: #bfdbfe; }
+.wg-arrow { transition: transform .2s; }
+.wg-toggle.wg-open .wg-arrow { transform: rotate(180deg); }
+.wg-body {
+  margin-top: 10px;
+  background: var(--bg);
+  border: 1px solid var(--border-l);
+  border-radius: var(--r);
+  padding: 14px;
+  animation: fadeIn .18s ease;
+}
+.wg-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+  gap: 16px;
+}
+.wg-section-title {
+  font-size: 11px;
+  font-weight: 700;
+  text-transform: uppercase;
+  letter-spacing: .06em;
+  color: var(--faint);
+  margin-bottom: 8px;
+}
+.wg-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  font-size: 12px;
+  margin-bottom: 5px;
+  color: var(--muted);
+}
+.wg-row code, .wg-mono {
+  font-family: var(--mono);
+  font-size: 11px;
+  background: var(--surface);
+  border: 1px solid var(--border);
+  padding: 1px 5px;
+  border-radius: 4px;
+  color: var(--text);
+}
+.wg-block-row { flex-direction: column; align-items: flex-start; }
+.wg-hint { font-size: 11px; color: var(--faint); font-style: italic; }
+[data-theme="dark"] .wg-body { background: var(--bg); border-color: var(--border); }
+[data-theme="dark"] .wg-row code, [data-theme="dark"] .wg-mono { background: var(--surface); border-color: var(--border); }
+
+/* ── Green karma redesign ─────────────────────────────────────── */
+.pkr-label-green    { color: #16a34a; font-weight: 700; }
+.pkr-fill-green     { background: linear-gradient(90deg, #22c55e, #16a34a) !important; }
+.profile-tier-badge-green {
+  background: rgba(34,197,94,.12);
+  border-color: rgba(34,197,94,.35);
+  color: #16a34a;
+  --tc: #16a34a;
+}
+.pstat.karma-stat strong { color: #16a34a !important; }
+
+[data-theme="dark"] .pkr-label-green   { color: #4ade80; }
+[data-theme="dark"] .pkr-fill-green    { background: linear-gradient(90deg,#22c55e,#16a34a) !important; }
+[data-theme="dark"] .profile-tier-badge-green { background: rgba(34,197,94,.15); border-color: rgba(34,197,94,.3); color: #4ade80; }
+[data-theme="dark"] .pstat.karma-stat strong  { color: #4ade80 !important; }
+[data-theme="dark"] .post-karma-badge  { background: rgba(34,197,94,.15); border-color: rgba(34,197,94,.3); color: #4ade80; }
+[data-theme="dark"] .post-karma-badge .pkb-icon svg { stroke: #4ade80 !important; }
+
+/* ── Security warning banner ───────────────────────────────── */
+.sec-warning {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+  margin: 0;
+  padding: 9px 14px;
+  background: #fffbeb;
+  border-top: 1px solid #fde68a;
+  color: #92400e;
+  font-size: 12px;
+  font-family: var(--font);
+  line-height: 1.5;
+}
+.sec-warning svg {
+  flex-shrink: 0;
+  margin-top: 1px;
+  color: #f59e0b;
+}
+[data-theme="dark"] .sec-warning {
+  background: #431407;
+  border-color: #9a3412;
+  color: #fed7aa;
+}
+[data-theme="dark"] .sec-warning svg { color: #fb923c; }
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..bfbfe40
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,1236 @@
+/* ================================================================
+   Nexus Forum — app.js  v2
+   ================================================================ */
+'use strict';
+
+/* ── Escape HTML ──────────────────────────────────────────── */
+function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
+
+/* ── Media embed detection ────────────────────────────────── */
+/* Called on bare URLs that end up in <p> tags after md() rendering */
+function tryEmbed(url) {
+  var m;
+  // YouTube full
+  m = url.match(/youtube\.com\/watch\?.*v=([a-zA-Z0-9_\-]{11})/);
+  if (m) return ytEmbed(m[1]);
+  // YouTube short
+  m = url.match(/youtu\.be\/([a-zA-Z0-9_\-]{11})/);
+  if (m) return ytEmbed(m[1]);
+  // YouTube Shorts
+  m = url.match(/youtube\.com\/shorts\/([a-zA-Z0-9_\-]{11})/);
+  if (m) return ytEmbed(m[1], true);
+  // Vimeo
+  m = url.match(/vimeo\.com\/(\d{5,12})/);
+  if (m) return embedIframe('https://player.vimeo.com/video/'+m[1]+'?dnt=1', 'Vimeo');
+  // Spotify
+  m = url.match(/open\.spotify\.com\/(track|album|playlist|episode|artist)\/([a-zA-Z0-9]+)/);
+  if (m) {
+    var h = (m[1]==='track'||m[1]==='episode') ? '152' : '352';
+    return '<div class="embed-spotify"><iframe src="https://open.spotify.com/embed/'+m[1]+'/'+m[2]+'" width="100%" height="'+h+'" frameborder="0" allow="autoplay;clipboard-write;encrypted-media;fullscreen" loading="lazy"></iframe></div>';
+  }
+  // SoundCloud
+  if (/soundcloud\.com\/[a-zA-Z0-9\-_]+\/[a-zA-Z0-9\-_]+/.test(url)) {
+    return '<div class="embed-sc"><iframe width="100%" height="166" scrolling="no" frameborder="no" src="https://w.soundcloud.com/player/?url='+encodeURIComponent(url)+'&color=%233b82f6&auto_play=false"></iframe></div>';
+  }
+  // Twitch
+  m = url.match(/twitch\.tv\/videos\/(\d+)/);
+  if (m) return embedIframe('https://player.twitch.tv/?video=v'+m[1]+'&parent='+location.hostname+'&autoplay=false', 'Twitch VOD');
+  m = url.match(/twitch\.tv\/([a-zA-Z0-9_]{4,25})(?:\/|$|\?)/);
+  if (m) return embedIframe('https://player.twitch.tv/?channel='+m[1]+'&parent='+location.hostname+'&autoplay=false', 'Twitch');
+  // Twitter/X
+  m = url.match(/(?:twitter|x)\.com\/[a-zA-Z0-9_]+\/status\/(\d+)/);
+  if (m) return '<div class="embed-tweet" data-tweet-id="'+m[1]+'"><a href="'+esc(url)+'" target="_blank" rel="noopener" class="tweet-fallback">🐦 View on Twitter/X →</a></div>';
+  // Streamable
+  m = url.match(/streamable\.com\/([a-zA-Z0-9]+)/);
+  if (m) return embedIframe('https://streamable.com/e/'+m[1], 'Streamable');
+  // Dailymotion
+  m = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/);
+  if (m) return embedIframe('https://www.dailymotion.com/embed/video/'+m[1], 'Dailymotion');
+  // Loom
+  m = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/);
+  if (m) return embedIframe('https://www.loom.com/embed/'+m[1], 'Loom');
+  // CodePen
+  m = url.match(/codepen\.io\/([a-zA-Z0-9\-_]+)\/pen\/([a-zA-Z0-9]+)/);
+  if (m) return embedIframeTall('https://codepen.io/'+m[1]+'/embed/'+m[2]+'?default-tab=result', 'CodePen', 420);
+  // JSFiddle
+  m = url.match(/jsfiddle\.net\/([a-zA-Z0-9\/]+)/);
+  if (m) return embedIframeTall('https://jsfiddle.net/'+m[1].replace(/\/$/,'')+'/embedded/result', 'JSFiddle', 380);
+  // TED
+  m = url.match(/ted\.com\/talks\/([a-zA-Z0-9_]+)/);
+  if (m) return embedIframe('https://embed.ted.com/talks/'+m[1], 'TED Talk');
+  return null;
+}
+
+function ytEmbed(id, isShort) {
+  var pad = isShort ? 'padding-bottom:177.78%;max-width:360px' : 'padding-bottom:56.25%';
+  return '<div class="embed-wrap" style="'+pad+'"><iframe class="embed-yt" src="https://www.youtube-nocookie.com/embed/'+esc(id)+'?rel=0&modestbranding=1" allowfullscreen loading="lazy" title="YouTube"></iframe></div>';
+}
+function embedIframe(src, label) {
+  return '<div class="embed-wrap"><iframe class="embed-yt" src="'+esc(src)+'" allowfullscreen loading="lazy" title="'+esc(label||'')+'"></iframe></div>';
+}
+function embedIframeTall(src, label, h) {
+  return '<div class="embed-wrap" style="padding-bottom:0;height:'+(h||400)+'px"><iframe class="embed-yt" src="'+esc(src)+'" allowfullscreen loading="lazy" title="'+esc(label||'')+'"></iframe></div>';
+}
+function processEmbeds(html) {
+  return html.replace(/<p>\s*(https?:\/\/[^\s<>"']+)\s*<\/p>/gi, function(match, url) {
+    var em = tryEmbed(url);
+    return em !== null ? em : match;
+  });
+}
+
+
+/* ── Markdown renderer ────────────────────────────────────── */
+function md(raw) {
+  if (!raw) return '';
+  var s = raw;
+  var fences = [], quotes = [], inlines = [];
+
+  // ── PASS 1: Extract code fences (state machine) ─────────────
+  var fenceLines = s.split('\n');
+  var fenceOut = [], inFence = false, fLang = '', fType = '', fBuf = [];
+
+  var buildFence = function(lang, lines) {
+    var code = lines.join('\n');
+    var ll   = lang ? lang.toLowerCase() : '';
+    var copyBtn = '<button class="cb-copy" onclick="cbCopy(this)" title="Copy" aria-label="Copy code">'
+      + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
+      + 'stroke-linecap="round" stroke-linejoin="round" width="13" height="13">'
+      + '<rect x="9" y="9" width="13" height="13" rx="2"/>'
+      + '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'
+      + '</svg><span>Copy</span></button>';
+    var hdr, blk;
+    if (ll) {
+      hdr = '<div class="cb-header"><span class="cb-lang">' + esc(ll) + '</span>' + copyBtn + '</div>';
+      blk = '<pre class="code-block" data-lang="' + esc(ll) + '">'
+          + '<code class="language-' + esc(ll) + '">' + esc(code) + '</code></pre>';
+    } else {
+      hdr = '<div class="cb-header cb-header-nolang">' + copyBtn + '</div>';
+      blk = '<pre class="code-block"><code>' + esc(code) + '</code></pre>';
+    }
+    var i = fences.length;
+    fences.push('<div class="code-block-wrap">' + hdr + blk + '</div>');
+    return '\x02FENCE' + i + 'FNCE\x03';
+  };
+
+  for (var fi = 0; fi < fenceLines.length; fi++) {
+    var fl = fenceLines[fi];
+    if (!inFence) {
+      var tm = fl.match(/^```([ \t]*\w*)[ \t]*$/);
+      if (tm) { inFence = true; fType = 'triple'; fLang = tm[1].trim(); fBuf = []; continue; }
+      if (/^`[ \t]*$/.test(fl)) { inFence = true; fType = 'single'; fLang = ''; fBuf = []; continue; }
+      fenceOut.push(fl);
+    } else {
+      var isClose = (fType === 'triple' && /^```[ \t]*$/.test(fl))
+                 || (fType === 'single' && /^`[ \t]*$/.test(fl));
+      if (isClose) { fenceOut.push(buildFence(fLang, fBuf)); inFence = false; fBuf = []; }
+      else          { fBuf.push(fl); }
+    }
+  }
+  if (inFence && fBuf.length) fenceOut.push(buildFence(fLang, fBuf));
+  s = fenceOut.join('\n');
+
+  // ── PASS 2: Extract blockquotes (state machine) ──────────────
+  var bqLines = s.split('\n'), bqOut = [], bqBuf = [];
+  var flushBq = function() {
+    if (!bqBuf.length) return;
+    var inner = bqBuf.join('\n');
+    inner = inner.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
+    inner = inner.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
+    inner = inner.replace(/\*([^\*\n]+)\*/g, '<em>$1</em>');
+    inner = inner.replace(/~~(.+?)~~/g, '<del>$1</del>');
+    var bqParts = inner.split('\n').filter(function(l){return l.trim();});
+    var content = bqParts.length > 1
+      ? bqParts.map(function(l){return '<p>'+l.trim()+'</p>';}).join('')
+      : inner.trim();
+    var i = quotes.length;
+    quotes.push('<blockquote class="post-quote">' + content + '</blockquote>');
+    bqOut.push('\x02BQUOT' + i + 'BQUT\x03');
+    bqBuf = [];
+  };
+  for (var bi = 0; bi < bqLines.length; bi++) {
+    var bm = bqLines[bi].match(/^(?:&gt;|>) ?(.*)/);
+    if (bm) { bqBuf.push(bm[1]); }
+    else    { flushBq(); bqOut.push(bqLines[bi]); }
+  }
+  flushBq();
+  s = bqOut.join('\n');
+
+  // ── PASS 3: Inline code ──────────────────────────────────────
+  s = s.replace(/`([^`\n]+)`/g, function(_, code) {
+    var i = inlines.length;
+    inlines.push('<code class="inline-code">' + esc(code) + '</code>');
+    return '\x02INLIN' + i + 'INLN\x03';
+  });
+
+  // ── PASS 4: Block markdown ───────────────────────────────────
+  s = s.replace(/^#{6} (.+)$/gm, '<h6>$1</h6>');
+  s = s.replace(/^#{5} (.+)$/gm, '<h5>$1</h5>');
+  s = s.replace(/^#{4} (.+)$/gm, '<h4>$1</h4>');
+  s = s.replace(/^#{3} (.+)$/gm, '<h3>$1</h3>');
+  s = s.replace(/^#{2} (.+)$/gm, '<h2>$1</h2>');
+  s = s.replace(/^# (.+)$/gm,   '<h1>$1</h1>');
+  s = s.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr>');
+
+  s = s.replace(/^[ \t]*[*\-+] (.+)$/gm, '<li>$1</li>');
+  s = s.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
+  s = s.replace(/<\/ul>\s*<ul>/g, '');
+
+  s = s.replace(/^[ \t]*\d+\. (.+)$/gm, '<oli>$1</oli>');
+  s = s.replace(/((?:<oli>.*<\/oli>\n?)+)/g, '<ol>$1</ol>');
+  s = s.replace(/<\/ol>\s*<ol>/g, '');
+  s = s.replace(/<oli>/g, '<li>').replace(/<\/oli>/g, '</li>');
+
+  // ── PASS 5: Inline markdown ──────────────────────────────────
+  s = s.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
+  s = s.replace(/\*\*(.+?)\*\*/g,     '<strong>$1</strong>');
+  s = s.replace(/\*([^\*\n]+)\*/g,    '<em>$1</em>');
+  s = s.replace(/___(.+?)___/g,       '<strong><em>$1</em></strong>');
+  s = s.replace(/__(.+?)__/g,         '<strong>$1</strong>');
+  s = s.replace(/_([^_\n]+)_/g,       '<em>$1</em>');
+  s = s.replace(/~~(.+?)~~/g,         '<del>$1</del>');
+
+  s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(_,alt,src){
+    if (!/^https?:\/\/|^\//.test(src)) return esc(_);
+    return '<img src="'+esc(src)+'" alt="'+esc(alt)+'" loading="lazy" onclick="lightbox(this)">';
+  });
+  s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_,txt,url){
+    if (!/^https?:\/\/|^\//.test(url)) return esc(txt);
+    return '<a href="'+esc(url)+'" target="_blank" rel="noopener noreferrer nofollow">'+esc(txt)+'</a>';
+  });
+
+  // ── PASS 6: Embeds + auto-link (ALL bare URLs) ───────────────
+  s = s.replace(/(^|[ \t]|<p>)(https?:\/\/[^\s<>"']+)/gm, function(_, pre, url) {
+    if (/^<(div|iframe|a|pre)/.test(url)) return pre + url;
+    var em = tryEmbed(url);
+    if (em) return pre + em;
+    return pre + '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer nofollow">' + esc(url) + '</a>';
+  });
+
+  // ── PASS 7: Paragraph wrapping ───────────────────────────────
+  var pLines = s.split('\n'), pOut = [], para = '';
+  var blockRe = /^(<(h[1-6]|ul|ol|blockquote|pre|table|hr|img|div|figure|p)|\x02FENCE|\x02BQUOT)/;
+  function flush(){ if(para.trim()){ pOut.push('<p>'+para.trim()+'</p>'); para=''; } }
+  for (var pi = 0; pi < pLines.length; pi++) {
+    var pl = pLines[pi];
+    if (!pl.trim())             { flush(); }
+    else if (blockRe.test(pl.trim())) { flush(); pOut.push(pl); }
+    else                        { para += (para ? ' ' : '') + pl; }
+  }
+  flush();
+  s = pOut.join('\n');
+
+  // ── PASS 8: Restore placeholders ─────────────────────────────
+  fences.forEach(function(b,i){  s = s.replace('\x02FENCE' + i + 'FNCE\x03', b); });
+  quotes.forEach(function(b,i){  s = s.replace('\x02BQUOT' + i + 'BQUT\x03', b); });
+  inlines.forEach(function(b,i){ s = s.replace('\x02INLIN' + i + 'INLN\x03', b); });
+
+  return s;
+}
+
+
+/* ── Render all .md elements ──────────────────────────────── */
+function renderAllMd() {
+  document.querySelectorAll('.md[data-raw]').forEach(function(el) {
+    el.innerHTML = md(atob(el.getAttribute('data-raw')));
+    el.removeAttribute('data-raw');
+  });
+  loadTwitterWidgets();
+}
+
+/* ── Lightbox ─────────────────────────────────────────────── */
+function lightbox(img) {
+  var ov = document.createElement('div');
+  ov.className = 'lightbox';
+  var im = document.createElement('img');
+  im.src = img.src; im.alt = img.alt;
+  ov.appendChild(im);
+  ov.onclick = function(){ov.remove();};
+  document.body.appendChild(ov);
+}
+
+/* ── Time-ago ─────────────────────────────────────────────── */
+function timeAgo(dt) {
+  var diff = Math.floor((Date.now() - new Date(dt)) / 1000);
+  if (diff < 60)      return 'just now';
+  if (diff < 3600)    return Math.floor(diff/60)    + 'm ago';
+  if (diff < 86400)   return Math.floor(diff/3600)  + 'h ago';
+  if (diff < 604800)  return Math.floor(diff/86400) + 'd ago';
+  return new Date(dt).toLocaleDateString();
+}
+function renderTimeAgo() {
+  document.querySelectorAll('.ago[data-ts]').forEach(function(el) {
+    el.textContent = timeAgo(el.dataset.ts);
+    el.title = new Date(el.dataset.ts).toLocaleString();
+  });
+}
+
+/* ── Sidebar toggle ───────────────────────────────────────── */
+var burger  = document.getElementById('burgerBtn');
+var sidebar = document.getElementById('sidebar');
+var sbOv    = document.getElementById('sbOverlay');
+if (burger && sidebar) {
+  burger.addEventListener('click', function(){
+    var open = sidebar.classList.toggle('open');
+    burger.classList.toggle('open', open);
+    if (sbOv) sbOv.classList.toggle('show', open);
+    document.body.style.overflow = open ? 'hidden' : '';
+  });
+  if (sbOv) sbOv.addEventListener('click', function(){
+    sidebar.classList.remove('open');
+    burger.classList.remove('open');
+    sbOv.classList.remove('show');
+    document.body.style.overflow = '';
+  });
+}
+
+/* ── Dropdowns ────────────────────────────────────────────── */
+function setupDrop(btnId, dropId, onOpen) {
+  var btn  = document.getElementById(btnId);
+  var drop = document.getElementById(dropId);
+  if (!btn || !drop) return;
+  btn.addEventListener('click', function(e) {
+    e.stopPropagation();
+    var opening = !drop.classList.contains('show');
+    document.querySelectorAll('.notif-drop.show,.user-drop.show').forEach(function(d){d.classList.remove('show');});
+    if (opening) { drop.classList.add('show'); if (onOpen) onOpen(); }
+  });
+}
+document.addEventListener('click', function(){
+  document.querySelectorAll('.notif-drop.show,.user-drop.show').forEach(function(d){d.classList.remove('show');});
+});
+setupDrop('notifBtn', 'notifDrop', loadNotifs);
+setupDrop('userBtn',  'userDrop');
+
+/* ── Notifications ────────────────────────────────────────── */
+function loadNotifs() {
+  if (!window.NX || !NX.user) return;
+  var list = document.getElementById('notifList');
+  if (!list) return;
+  fetch(NX.base + '/api/notifications.php')
+    .then(function(r){ return r.json(); })
+    .then(function(rows) {
+      if (!rows.length) { list.innerHTML = '<p class="notif-empty">Nothing new 🎉</p>'; return; }
+      list.innerHTML = rows.map(function(n) {
+        var d = n.payload || {};
+        var text = (n.type === 'reply' && d.title)
+          ? '<strong>@'+esc(d.from)+'</strong> replied in <em>'+esc(d.title)+'</em>'
+          : esc(n.type);
+        var href = (n.type === 'reply' && d.slug) ? NX.base+'/forum/topic.php?slug='+encodeURIComponent(d.slug) : '#';
+        return '<a href="'+href+'" class="notif-item'+(n.read?'':' unread')+'">'
+          +'<div class="notif-item-text">'+text+'</div>'
+          +'<div class="notif-item-time">'+timeAgo(n.created_at)+'</div>'
+          +'</a>';
+      }).join('');
+    })
+    .catch(function(){ if (list) list.innerHTML = '<p class="notif-empty">Failed to load</p>'; });
+}
+var readAllBtn = document.getElementById('readAllBtn');
+if (readAllBtn) {
+  readAllBtn.addEventListener('click', function(){
+    fetch(NX.base + '/api/notifications.php?action=read').then(function(){
+      var dot = document.querySelector('.badge-dot');
+      if (dot) dot.remove();
+      loadNotifs();
+    });
+  });
+}
+
+/* ── Header search ────────────────────────────────────────── */
+var searchInput   = document.getElementById('searchInput');
+var searchResults = document.getElementById('searchResults');
+var searchTimer   = null;
+if (searchInput && searchResults) {
+  searchInput.addEventListener('input', function(){
+    clearTimeout(searchTimer);
+    var q = searchInput.value.trim();
+    if (q.length < 2) { searchResults.classList.remove('show'); return; }
+    searchTimer = setTimeout(function(){
+      fetch(NX.base + '/api/search.php?q=' + encodeURIComponent(q))
+        .then(function(r){ return r.json(); })
+        .then(function(d){
+          var topics = Array.isArray(d) ? d : (d.topics || []);
+          var posts  = Array.isArray(d) ? [] : (d.posts  || []);
+          if (!topics.length && !posts.length) {
+            searchResults.classList.remove('show'); return;
+          }
+          var html = '';
+          if (topics.length) {
+            html += '<div class="sr-section-lbl">Topics</div>';
+            topics.forEach(function(r){
+              html += '<a class="sr-item" href="'+NX.base+'/forum/topic.php?slug='+encodeURIComponent(r.slug)+'">'
+                + '<span class="sr-dot" style="background:'+(r.cat_color||'#3b82f6')+'"></span>'
+                + '<span class="sr-text"><strong>'+esc(r.title)+'</strong>'
+                + '<small>'+esc(r.cat)+'</small></span></a>';
+            });
+          }
+          if (posts.length) {
+            html += '<div class="sr-section-lbl">Matching Posts</div>';
+            posts.forEach(function(r){
+              var preview = (r.content||'').substring(0,65).replace(/\n/g,' ');
+              var url = NX.base+'/forum/topic.php?slug='+encodeURIComponent(r.topic_slug)+'&goto='+r.post_id+'#post-'+r.post_id;
+              html += '<a class="sr-item sr-post" href="'+url+'">'
+                + '<span class="sr-text">'
+                + '<strong>'+esc(r.topic_title)+'</strong>'
+                + '<small>Post #'+r.post_num+' by @'+esc(r.username)+': '+esc(preview)+'…</small>'
+                + '</span>'
+                + '<span class="sr-goto-badge">→</span></a>';
+            });
+          }
+          html += '<a class="sr-item sr-all" href="'+NX.base+'/forum/search.php?q='+encodeURIComponent(q)+'&type=posts">'
+                + '🔍 See all results for &ldquo;'+esc(q)+'&rdquo;</a>';
+          searchResults.innerHTML = html;
+          searchResults.classList.add('show');
+        });
+    }, 280);
+  });
+  searchInput.addEventListener('keydown', function(e){
+    if (e.key === 'Enter') window.location = NX.base + '/forum/search.php?q=' + encodeURIComponent(searchInput.value);
+    if (e.key === 'Escape') searchResults.classList.remove('show');
+  });
+  document.addEventListener('click', function(e){
+    if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) searchResults.classList.remove('show');
+  });
+}
+
+/* ── Editor commands ──────────────────────────────────────── */
+function fmt(cmd, taId) {
+  var ta = document.getElementById(taId || 'replyTa');
+  if (!ta) return;
+  var s = ta.selectionStart, e = ta.selectionEnd;
+  var sel = ta.value.slice(s, e);
+  var pre = ta.value.slice(0, s);
+  var post = ta.value.slice(e);
+
+  // ── Link ──────────────────────────────────────────────────────
+  if (cmd === 'link') {
+    var url = prompt('Enter URL:');
+    if (!url) return;
+    var txt = sel || 'link text';
+    ta.value = pre + '[' + txt + '](' + url + ')' + post;
+    ta.selectionStart = s + txt.length + url.length + 4;
+    ta.selectionEnd   = ta.selectionStart;
+    ta.focus(); ta.dispatchEvent(new Event('input')); return;
+  }
+
+  // ── Quote — prefix EVERY selected line with "> " ──────────────
+  // Mirrors Discourse / GitHub behaviour:
+  //   • each line of the selection gets its own "> " prefix
+  //   • trailing newline in the selection is stripped so no ghost blank line
+  //   • if nothing is selected, inserts a placeholder quote line
+  //   • a blank separator line is added before/after so the block renders correctly
+  if (cmd === 'quote') {
+    var text  = sel ? sel.replace(/\n+$/, '') : '';   // strip trailing newlines
+    var lines = text ? text.split('\n') : ['quoted text'];
+    var quoted = lines.map(function(l) { return '> ' + l; }).join('\n');
+
+    // Ensure there's a blank line before the quote block (so renderer sees it as a block)
+    var needPre = pre.length > 0 && !/\n\n$/.test(pre) && !pre.endsWith('\n');
+    var before  = needPre ? '\n\n' : (pre.length > 0 && !pre.endsWith('\n') ? '\n' : '');
+
+    // Ensure there's a blank line after so the next paragraph starts clean
+    var needPost = post.length > 0 && !post.startsWith('\n\n') && !post.startsWith('\n');
+    var after    = needPost ? '\n\n' : '\n';
+
+    ta.value = pre + before + quoted + after + post;
+
+    // Select the quoted lines so the user can see exactly what was quoted
+    var qStart = s + before.length;
+    var qEnd   = qStart + quoted.length;
+    ta.selectionStart = qStart;
+    ta.selectionEnd   = qEnd;
+    ta.focus();
+    ta.dispatchEvent(new Event('input'));
+    return;
+  }
+
+  // ── All other commands ─────────────────────────────────────────
+  var map = {
+    bold:      ['**', '**', 'bold text'],
+    italic:    ['*',  '*',  'italic text'],
+    strike:    ['~~', '~~', 'strikethrough'],
+    code:      ['```\n', '\n```', 'code here'],
+    codeblock: ['```\n', '\n```', 'code here'],
+    heading:   ['## ', '', 'Heading'],
+    ul:        ['- ',  '', 'list item'],
+  };
+  var c = map[cmd];
+  if (!c) return;
+  var text    = sel || c[2];
+  var newText = c[0] + text + c[1];
+  ta.value          = pre + newText + post;
+  ta.selectionStart = s + c[0].length;
+  ta.selectionEnd   = s + c[0].length + text.length;
+  ta.focus(); ta.dispatchEvent(new Event('input'));
+}
+
+/* ── Preview toggle ───────────────────────────────────────── */
+function togglePreview() {
+  var ta   = document.getElementById('replyTa');
+  var prev = document.getElementById('replyPreview');
+  var btn  = document.getElementById('prevBtn');
+  if (!ta || !prev) return;
+  if (prev.classList.contains('hidden')) {
+    prev.innerHTML = md(ta.value);
+    loadTwitterWidgets();
+    prev.classList.remove('hidden');
+    ta.classList.add('hidden');
+    if (btn) btn.classList.add('on');
+  } else {
+    prev.classList.add('hidden');
+    ta.classList.remove('hidden');
+    if (btn) btn.classList.remove('on');
+  }
+}
+
+/* ── Reply submit ─────────────────────────────────────────── */
+function sendReply(slug) {
+  var ta = document.getElementById('replyTa');
+  if (!ta) return;
+  var content = ta.value.trim();
+  if (!content) { toast('Reply cannot be empty', 'err'); return; }
+
+  var btn = document.getElementById('replyBtn');
+  if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; }
+
+  var fd = new FormData();
+  fd.append('slug', slug);
+  fd.append('content', content);
+  fd.append('csrf', NX.csrf);
+
+  // Attach post captcha if present
+  var capInp = document.getElementById('postCaptchaInput');
+  if (capInp) fd.append('post_captcha', capInp.value);
+
+  fetch(NX.base + '/api/reply.php', {method:'POST', body:fd})
+    .then(function(r){ return r.json().then(function(d){ d._status=r.status; return d; }); })
+    .then(function(data){
+      if (data.ok) {
+        // Clear editor
+        ta.value = '';
+        var prev = document.getElementById('replyPreview');
+        if (prev) prev.classList.add('hidden');
+        ta.classList.remove('hidden');
+        var prevBtn = document.getElementById('prevBtn');
+        if (prevBtn) prevBtn.classList.remove('on');
+        var cnt = document.getElementById('charCnt');
+        if (cnt) { cnt.textContent = '0'; cnt.style.color = ''; }
+
+        // Update captcha if server sent a new one
+        if (data.new_captcha && capInp) {
+          var ql = capInp.closest('.post-captcha-row')&&capInp.closest('.post-captcha-row').querySelector('.captcha-q');
+          if (ql) ql.textContent = data.new_captcha.q;
+          capInp.value = '';
+        }
+
+        // Append post to list
+        var list = document.getElementById('postsList');
+        if (list && data.post) {
+          // Server-rendered post via reload or AJAX HTML
+          list.insertAdjacentHTML('beforeend', buildPost(data.post));
+          var np = list.lastElementChild;
+          renderTimeAgo();
+          loadTwitterWidgets();
+          np.scrollIntoView({behavior:'smooth',block:'center'});
+        } else { location.reload(); }
+        toast('Reply posted!', 'ok');
+
+      } else if (data.rate_limited) {
+        // Rate limit — show countdown
+        startRateCountdown(data.wait || 30);
+        toast(data.error, 'warn');
+
+      } else if (data.captcha_failed) {
+        toast(data.error, 'err');
+        // Reload page to get new captcha
+        setTimeout(function(){ location.reload(); }, 1500);
+
+      } else {
+        toast(data.error || 'Failed to post', 'err');
+      }
+    })
+    .catch(function(){ toast('Network error — please try again', 'err'); })
+    .finally(function(){ if (btn) { btn.disabled=false; btn.textContent='Post Reply'; } });
+}
+
+/* Rate limit countdown */
+function startRateCountdown(seconds) {
+  var info = document.getElementById('rateLimitInfo');
+  var btn  = document.getElementById('replyBtn');
+  var msg  = document.getElementById('rateLimitMsg');
+  var cd   = document.getElementById('rateCountdown');
+  if (info) info.style.display = '';
+  if (btn)  btn.disabled = true;
+  var remaining = seconds;
+  function tick() {
+    if (msg) msg.textContent = 'Please wait before posting again:';
+    if (cd)  cd.textContent = remaining + 's';
+    if (remaining <= 0) {
+      if (info) info.style.display = 'none';
+      if (btn)  btn.disabled = false;
+      if (cd)   cd.textContent = '';
+      return;
+    }
+    remaining--;
+    setTimeout(tick, 1000);
+  }
+  tick();
+}
+
+function buildPost(p) {
+  var av = p.avatar
+    ? '<img src="'+esc(p.avatar)+'" class="av-lg" alt="">'
+    : '<span class="av-lg av-init">'+esc(p.username[0].toUpperCase())+'</span>';
+  var flair = p.role==='admin' ? '<span class="role-flair admin">Admin</span>'
+    : p.role==='moderator' ? '<span class="role-flair mod">Mod</span>' : '';
+  return '<div class="post" id="post-'+p.id+'">'
+    +'<div class="post-side">'+av
+    +'<a href="'+NX.base+'/users/profile.php?u='+encodeURIComponent(p.username)+'" class="post-name">@'+esc(p.username)+'</a>'
+    +flair+'<span class="post-pcnt">'+p.post_count+' posts</span></div>'
+    +'<div class="post-body"><div class="post-meta-bar"><span class="pnum">#'+p.post_num+'</span>'
+    +'<time class="ago" data-ts="'+esc(p.created_at)+'"></time>'
+    +'<div class="post-acts">'
+    +'<button class="pa-btn" onclick="doLike('+p.id+',this)">♥ <span class="lc">0</span></button>'
+    +'<button class="pa-btn" onclick="doQuote('+p.id+',\''+esc(p.username)+'\')">↩ Reply</button>'
+    +'</div></div>'
+    +'<div class="post-content rendered-post" id="pc-'+p.id+'" data-raw="'+btoa(unescape(encodeURIComponent(p.content)))+'">'
+    +md(p.content)+'</div></div></div>';
+}
+
+/* ── Like ─────────────────────────────────────────────────── */
+function doLike(pid, btn) {
+  if (!NX.user) { toast('Log in to like posts', 'warn'); return; }
+  var fd = new FormData(); fd.append('post_id', pid); fd.append('csrf', NX.csrf);
+  fetch(NX.base + '/api/like.php', {method:'POST', body:fd})
+    .then(function(r){ return r.json(); })
+    .then(function(data){
+      if (data.ok) {
+        var lc = btn.querySelector('.lc');
+        btn.classList.toggle('liked', data.liked);
+        if (lc) lc.textContent = data.count;
+      }
+    });
+}
+
+/* ── Quote reply ──────────────────────────────────────────── */
+function doQuote(pid, uname) {
+  var bodyEl = document.getElementById('pc-'+pid);
+  var text   = bodyEl ? bodyEl.innerText.trim().slice(0,300) : '';
+  var ta     = document.getElementById('replyTa');
+  if (!ta) return;
+  ta.value = '> **@'+uname+'** wrote:\n> '+text.replace(/\n/g,'\n> ')+'\n\n' + ta.value;
+  ta.focus();
+  ta.scrollIntoView({behavior:'smooth'});
+}
+
+/* ── Edit post ────────────────────────────────────────────── */
+function doEdit(pid) {
+  var box  = document.getElementById('eb-'+pid);
+  var body = document.getElementById('pc-'+pid);
+  var ta   = document.getElementById('et-'+pid);
+  if (!box||!body||!ta) return;
+  // Use innerText for plaintext content (already sanitised, we edit raw)
+  var rawAttr = body.getAttribute('data-raw');
+  ta.value = rawAttr ? atob(rawAttr) : body.innerText;
+  box.classList.add('visible');
+  box.classList.remove('hidden');
+  body.classList.add('hidden');
+  ta.focus();
+}
+function cancelEdit(pid) {
+  var box  = document.getElementById('eb-'+pid);
+  var body = document.getElementById('pc-'+pid);
+  if (box)  { box.classList.remove('visible'); box.classList.add('hidden'); }
+  if (body) body.classList.remove('hidden');
+}
+function saveEdit(pid) {
+  var ta = document.getElementById('et-'+pid);
+  var ri = document.getElementById('er-'+pid);
+  if (!ta) return;
+  var fd = new FormData();
+  fd.append('post_id',pid);
+  fd.append('content', ta.value);
+  fd.append('reason',  ri ? ri.value : '');
+  fd.append('csrf', NX.csrf);
+  fetch(NX.base+'/api/edit.php', {method:'POST',body:fd})
+    .then(function(r){ return r.json(); })
+    .then(function(data){
+      if (data.ok) {
+        var body = document.getElementById('pc-'+pid);
+        if (body) {
+          // Re-render using client-side md() + mention rendering
+          body.innerHTML = renderMentions(md(data.content));
+          // Update data-raw so next edit reads correct value
+          try { body.setAttribute('data-raw', btoa(unescape(encodeURIComponent(data.content)))); } catch(e){}
+          body.classList.remove('hidden');
+          loadTwitterWidgets();
+        }
+        cancelEdit(pid);
+        toast('Post updated!','ok');
+        var meta = document.querySelector('#post-'+pid+' .post-meta-bar');
+        if (meta && !meta.querySelector('.edit-lbl')) {
+          var em = document.createElement('em');
+          em.className = 'edit-lbl';
+          em.textContent = ' (edited)';
+          var t = meta.querySelector('time');
+          if (t) t.after(em);
+        }
+      } else {
+        toast(data.error || 'Failed to save', 'err');
+      }
+    })
+    .catch(function(){ toast('Network error', 'err'); });
+}
+
+/* ── Delete post ──────────────────────────────────────────── */
+function doDelete(pid) {
+  if (!confirm('Delete this post?')) return;
+  var fd = new FormData(); fd.append('post_id',pid); fd.append('csrf',NX.csrf);
+  fetch(NX.base+'/api/delete.php', {method:'POST',body:fd})
+    .then(function(r){ return r.json(); })
+    .then(function(data){
+      if (data.ok) {
+        var el = document.getElementById('post-'+pid);
+        if (el) { el.style.opacity='.3'; el.style.pointerEvents='none';
+          var b = document.getElementById('pc-'+pid);
+          if (b) b.innerHTML='<em style="color:#94a3b8">This post has been deleted.</em>'; }
+        toast('Deleted','ok');
+      }
+    });
+}
+
+/* ── Image upload ─────────────────────────────────────────── */
+function pickImg(inputId, taId) {
+  var inp = document.getElementById(inputId);
+  if (inp) { inp._taId = taId; inp.click(); }
+}
+function uploadImg(inp, taId) {
+  if (inp.files && inp.files[0]) uploadFileToEditor(inp.files[0], taId || inp._taId || 'replyTa');
+}
+function uploadFileToEditor(file, taId) {
+  var ta = document.getElementById(taId || 'replyTa');
+  if (!ta) return;
+
+  // Client-side size check — max from server setting (NX.maxUploadMb, default 5)
+  var maxMb = (NX.maxUploadMb || 5);
+  if (file.size > maxMb * 1024 * 1024) {
+    toast('Image too large — max ' + maxMb + ' MB', 'err');
+    return;
+  }
+
+  var ph  = '![Uploading ' + file.name + '…]()';
+  var cur = ta.selectionStart;
+  ta.value = ta.value.slice(0, cur) + ph + ta.value.slice(cur);
+  toast('Uploading…', 'warn');
+
+  var fd = new FormData();
+  fd.append('file', file);
+  fd.append('csrf', NX.csrf);   // ← CSRF token (was missing — caused 403)
+
+  fetch(NX.base + '/api/upload.php', { method: 'POST', body: fd })
+    .then(function(r) {
+      // Always try to parse JSON — even error responses are JSON
+      return r.json().then(function(data) {
+        return { ok: r.ok, data: data };
+      });
+    })
+    .then(function(result) {
+      var data = result.data;
+      if (data.ok) {
+        ta.value = ta.value.replace(ph, '![' + file.name + '](' + data.url + ')');
+        toast('Image uploaded!', 'ok');
+      } else {
+        ta.value = ta.value.replace(ph, '');
+        toast(data.error || 'Upload failed', 'err');
+      }
+      ta.dispatchEvent(new Event('input'));
+    })
+    .catch(function(err) {
+      ta.value = ta.value.replace(ph, '');
+      toast('Upload failed — check console for details', 'err');
+      console.error('Upload error:', err);
+    });
+}
+
+/* ── DOMContentLoaded ─────────────────────────────────────── */
+document.addEventListener('DOMContentLoaded', function(){
+  var ta  = document.getElementById('replyTa');
+  var cnt = document.getElementById('charCnt');
+  if (ta) {
+    if (cnt) { ta.addEventListener('input', function(){ var n=ta.value.length; cnt.textContent=n; cnt.style.color=n>19000?'var(--red)':n>15000?'var(--amber)':''; }); }
+    ta.addEventListener('paste', function(e){
+      var items = (e.clipboardData || e.originalEvent.clipboardData).items;
+      for (var i=0; i<items.length; i++) {
+        if (items[i].type.indexOf('image')!==-1) { e.preventDefault(); uploadFileToEditor(items[i].getAsFile(),'replyTa'); break; }
+      }
+    });
+    ta.addEventListener('dragover',  function(e){ e.preventDefault(); ta.classList.add('dragging'); });
+    ta.addEventListener('dragleave', function(){ ta.classList.remove('dragging'); });
+    ta.addEventListener('drop', function(e){
+      e.preventDefault(); ta.classList.remove('dragging');
+      var files = e.dataTransfer.files;
+      for (var i=0; i<files.length; i++) { if (files[i].type.startsWith('image/')) uploadFileToEditor(files[i],'replyTa'); }
+    });
+  }
+  renderAllMd();
+  renderTimeAgo();
+  setInterval(renderTimeAgo, 60000);
+});
+
+/* ── Toast ────────────────────────────────────────────────── */
+var _tc = 0;
+function toast(msg, type) {
+  var col = {ok:'#22c55e',err:'#ef4444',warn:'#f59e0b'}[type||'ok'] || '#3b82f6';
+  var t = document.createElement('div');
+  t.style.cssText = 'position:fixed;bottom:'+(20+_tc*56)+'px;right:20px;'
+    +'background:'+col+';color:#fff;padding:11px 16px;border-radius:8px;'
+    +'font-size:14px;font-family:var(--font,sans-serif);box-shadow:0 4px 16px rgba(0,0,0,.2);'
+    +'z-index:9999;max-width:320px;line-height:1.4;animation:fadeIn .25s ease';
+  t.textContent = msg;
+  document.body.appendChild(t);
+  _tc++;
+  setTimeout(function(){ t.style.transition='opacity .3s'; t.style.opacity='0'; setTimeout(function(){ t.remove(); _tc=Math.max(0,_tc-1); },300); }, 3400);
+}
+
+/* ================================================================
+   @mention autocomplete
+   ================================================================ */
+(function () {
+  var popup     = null;
+  var popupItems= [];
+  var popupIdx  = -1;
+  var mentionStart = -1;
+  var mentionTA    = null;
+
+  function createPopup() {
+    if (popup) return;
+    popup = document.createElement('div');
+    popup.className = 'mention-popup';
+    popup.id = 'mentionPopup';
+    document.body.appendChild(popup);
+  }
+
+  function showPopup(ta, users) {
+    createPopup();
+    popupItems = users;
+    popupIdx   = -1;
+    if (!users.length) { hidePopup(); return; }
+    popup.innerHTML = users.map(function(u, i){
+      var av = u.avatar
+        ? '<img src="'+esc(u.avatar)+'" class="av-xs" alt="">'
+        : '<span class="av-xs">'+esc(u.username[0].toUpperCase())+'</span>';
+      return '<div class="mention-item" data-i="'+i+'" onclick="insertMention('+i+')">'+av+'@'+esc(u.username)+'</div>';
+    }).join('');
+    popup.classList.add('show');
+
+    // Position popup below cursor
+    var rect   = ta.getBoundingClientRect();
+    var coords = getCaretCoords(ta, ta.selectionStart);
+    var top    = rect.top + window.scrollY + coords.top + 20;
+    var left   = rect.left + window.scrollX + coords.left;
+    popup.style.top  = top  + 'px';
+    popup.style.left = left + 'px';
+    popup.style.position = 'absolute';
+  }
+
+  function hidePopup() {
+    if (popup) { popup.classList.remove('show'); popup.innerHTML = ''; }
+    mentionStart = -1; mentionTA = null; popupItems = []; popupIdx = -1;
+  }
+
+  window.insertMention = function(idx) {
+    if (!mentionTA || idx < 0 || idx >= popupItems.length) return;
+    var u    = popupItems[idx];
+    var val  = mentionTA.value;
+    var pre  = val.slice(0, mentionStart);
+    var post = val.slice(mentionTA.selectionStart);
+    var ins  = '@' + u.username + ' ';
+    mentionTA.value = pre + ins + post;
+    var pos  = (pre + ins).length;
+    mentionTA.selectionStart = mentionTA.selectionEnd = pos;
+    mentionTA.focus();
+    hidePopup();
+  };
+
+  function handleMentionKey(e) {
+    if (!popup || !popup.classList.contains('show')) return;
+    if (e.key === 'ArrowDown') {
+      e.preventDefault();
+      popupIdx = (popupIdx + 1) % popupItems.length;
+      updateSelected();
+    } else if (e.key === 'ArrowUp') {
+      e.preventDefault();
+      popupIdx = (popupIdx - 1 + popupItems.length) % popupItems.length;
+      updateSelected();
+    } else if (e.key === 'Enter' || e.key === 'Tab') {
+      if (popupIdx >= 0) { e.preventDefault(); insertMention(popupIdx); }
+      else hidePopup();
+    } else if (e.key === 'Escape') {
+      hidePopup();
+    }
+  }
+
+  function updateSelected() {
+    popup.querySelectorAll('.mention-item').forEach(function(el, i){
+      el.classList.toggle('selected', i === popupIdx);
+      if (i === popupIdx) el.scrollIntoView({block:'nearest'});
+    });
+  }
+
+  var mentionTimer;
+  function handleMentionInput(ta) {
+    var val   = ta.value;
+    var caret = ta.selectionStart;
+    // Find the @ that triggers a mention (word char follows)
+    var before = val.slice(0, caret);
+    var match  = before.match(/@([a-zA-Z0-9_\-]*)$/);
+    if (!match) { hidePopup(); return; }
+    var query  = match[1];
+    mentionStart = caret - match[0].length;
+    mentionTA    = ta;
+    if (query.length < 1) { hidePopup(); return; }
+    clearTimeout(mentionTimer);
+    mentionTimer = setTimeout(function(){
+      fetch(NX.base + '/api/search_users.php?q=' + encodeURIComponent(query))
+        .then(function(r){ return r.json(); })
+        .then(function(users){ showPopup(ta, users); })
+        .catch(function(){ hidePopup(); });
+    }, 200);
+  }
+
+  // Attach to all textareas present or added
+  function attachMention(ta) {
+    if (ta.dataset.mentionBound) return;
+    ta.dataset.mentionBound = '1';
+    ta.addEventListener('input',  function(){ handleMentionInput(ta); });
+    ta.addEventListener('keydown', handleMentionKey);
+    ta.addEventListener('blur', function(){ setTimeout(hidePopup, 200); });
+  }
+
+  document.addEventListener('DOMContentLoaded', function(){
+    document.querySelectorAll('textarea.reply-ta, textarea.edit-ta').forEach(attachMention);
+    // Also attach to dynamically added textareas via MutationObserver
+    new MutationObserver(function(muts){
+      muts.forEach(function(m){
+        m.addedNodes.forEach(function(n){
+          if (n.nodeType!==1) return;
+          n.querySelectorAll && n.querySelectorAll('textarea.reply-ta,textarea.edit-ta').forEach(attachMention);
+          if (n.matches && (n.matches('textarea.reply-ta')||n.matches('textarea.edit-ta'))) attachMention(n);
+        });
+      });
+    }).observe(document.body, {childList:true,subtree:true});
+  });
+
+  /* Simple caret coordinate helper */
+  function getCaretCoords(el, pos) {
+    var div   = document.createElement('div');
+    var style = getComputedStyle(el);
+    ['fontFamily','fontSize','fontWeight','lineHeight','padding','border','whiteSpace','wordWrap'].forEach(function(p){
+      div.style[p] = style[p];
+    });
+    div.style.position = 'absolute'; div.style.visibility = 'hidden';
+    div.style.overflow = 'auto';     div.style.width = el.offsetWidth + 'px';
+    var text = el.value.slice(0, pos);
+    div.textContent = text;
+    var span = document.createElement('span');
+    span.textContent = '|';
+    div.appendChild(span);
+    document.body.appendChild(div);
+    var coords = { top: span.offsetTop, left: span.offsetLeft };
+    document.body.removeChild(div);
+    return coords;
+  }
+})();
+
+/* ================================================================
+   @mention rendering in post content
+   Convert @username text → clickable mention links
+   ================================================================ */
+function renderMentions(html) {
+  return html.replace(/@([a-zA-Z0-9_\-]{3,30})/g, function(_, uname) {
+    return '<a href="' + NX.base + '/users/profile.php?u=' + encodeURIComponent(uname) +
+      '" class="mention-tag">@' + esc(uname) + '</a>';
+  });
+}
+
+/* Patch md() to run renderMentions after rendering */
+var _origMd = md;
+md = function(raw) {
+  return renderMentions(_origMd(raw));
+};
+
+/* ================================================================
+   Extended notification renderer (friend requests, mentions, messages)
+   ================================================================ */
+var _origLoadNotifs = loadNotifs;
+loadNotifs = function() {
+  if (!window.NX || !NX.user) return;
+  var list = document.getElementById('notifList');
+  if (!list) return;
+  fetch(NX.base + '/api/notifications.php')
+    .then(function(r){ return r.json(); })
+    .then(function(rows){
+      if (!rows.length) { list.innerHTML = '<p class="notif-empty">Nothing new 🎉</p>'; return; }
+      list.innerHTML = rows.map(function(n) {
+        var d = n.payload || {};
+        var text = '';
+        var href = '#';
+        switch (n.type) {
+          case 'reply':
+            text = '<strong>@'+esc(d.from||'')+'</strong> replied in <em>'+esc(d.title||'')+'</em>';
+            if (d.slug) href = NX.base+'/forum/topic.php?slug='+encodeURIComponent(d.slug);
+            break;
+          case 'mention':
+            text = '<strong>@'+esc(d.from||'')+'</strong> mentioned you in a post';
+            if (d.topicSlug) href = NX.base+'/forum/topic.php?slug='+encodeURIComponent(d.topicSlug)+'#post-'+(d.postId||'');
+            break;
+          case 'friend_request':
+            text = '<strong>@'+esc(d.from||'')+'</strong> sent you a friend request';
+            href = NX.base+'/users/profile.php?u='+encodeURIComponent(d.from||'');
+            break;
+          case 'friend_accepted':
+            text = '<strong>@'+esc(d.from||'')+'</strong> accepted your friend request 🎉';
+            href = NX.base+'/users/profile.php?u='+encodeURIComponent(d.from||'');
+            break;
+          case 'message':
+            text = '<strong>@'+esc(d.from||'')+'</strong> sent you a message: <em>'+esc(d.subject||'')+'</em>';
+            href = NX.base+'/messages/';
+            break;
+          case 'karma_admin':
+            var diff = d.change||0;
+            var sign = diff >= 0 ? '+' : '';
+            text = 'Your karma was adjusted by an admin: <strong>'+sign+diff+'</strong>'
+                 + (d.new ? ' (now '+d.new+')' : '')
+                 + (d.reason ? ' — <em>'+esc(d.reason)+'</em>' : '');
+            href = NX.base+'/users/profile.php?u='+encodeURIComponent(NX.user.name||'');
+            break;
+          default:
+            text = esc(n.type);
+        }
+        return '<a href="'+href+'" class="notif-item'+(n.read?'':' unread')+'">'
+          +'<div class="notif-item-text">'+text+'</div>'
+          +'<div class="notif-item-time">'+timeAgo(n.created_at)+'</div>'
+          +'</a>';
+      }).join('');
+    })
+    .catch(function(){ if (list) list.innerHTML = '<p class="notif-empty">Failed to load</p>'; });
+};
+
+/* ================================================================
+   Edit toggle — show edit form only when Edit button clicked
+   (edit button itself is always visible; form is hidden by default)
+   ================================================================ */
+/* doEdit() already exists above — we just make sure edit-box starts hidden via CSS */
+
+/* ================================================================
+   Twitter/X widget loader
+   ================================================================ */
+function loadTwitterWidgets() {
+  var tweets = document.querySelectorAll('.embed-tweet[data-tweet-id]:not([data-loaded])');
+  if (!tweets.length) return;
+  function doLoad() {
+    tweets.forEach(function(el) {
+      el.setAttribute('data-loaded','1');
+      var id = el.dataset.tweetId;
+      if (id && window.twttr && window.twttr.widgets) {
+        window.twttr.widgets.createTweet(id, el, {theme:'light',dnt:true,align:'left'});
+      }
+    });
+  }
+  if (window.twttr && window.twttr.widgets) { doLoad(); return; }
+  if (document.querySelector('script[src*="platform.twitter.com"]')) {
+    // Already loading
+    var interval = setInterval(function(){
+      if (window.twttr && window.twttr.widgets) { clearInterval(interval); doLoad(); }
+    },300);
+    return;
+  }
+  var s = document.createElement('script');
+  s.src = 'https://platform.twitter.com/widgets.js';
+  s.async = true;
+  s.onload = doLoad;
+  document.head.appendChild(s);
+}
+
+/* ================================================================
+   Lazy load embeds — for heavy iframes (only load when in viewport)
+   ================================================================ */
+document.addEventListener('DOMContentLoaded', function(){
+  loadTwitterWidgets();
+
+  // IntersectionObserver for lazy embed loading
+  if ('IntersectionObserver' in window) {
+    var obs = new IntersectionObserver(function(entries){
+      entries.forEach(function(entry){
+        if (entry.isIntersecting) {
+          var iframe = entry.target;
+          if (iframe.dataset.src) {
+            iframe.src = iframe.dataset.src;
+            iframe.removeAttribute('data-src');
+            obs.unobserve(iframe);
+          }
+        }
+      });
+    }, {rootMargin:'200px'});
+
+    document.querySelectorAll('iframe.embed-yt[data-src]').forEach(function(el){
+      obs.observe(el);
+    });
+  }
+});
+
+/* ================================================================
+   Post content — use server-rendered HTML, add edit toggle via CSS
+   edit-box already hidden in CSS (display:none on .edit-box.hidden)
+   ================================================================ */
+/* buildPost is used for AJAX-appended posts (no server rendering there) */
+/* so we keep client-side md() for those */
+
+/* ── Post permalink copy ─────────────────────────────────── */
+function copyPostLink(postId, btn) {
+  var baseUrl = btn.getAttribute('data-url');
+  var full = window.location.protocol + '//' + window.location.host + baseUrl;
+  var icon_link = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
+  var icon_check = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>';
+  function showCopied() {
+    btn.innerHTML = icon_check;
+    btn.title = 'Copied!';
+    setTimeout(function(){ btn.innerHTML = icon_link; btn.title = 'Copy link to this post'; }, 2000);
+    toast('Post link copied!', 'ok');
+  }
+  if (navigator.clipboard && navigator.clipboard.writeText) {
+    navigator.clipboard.writeText(full).then(showCopied).catch(function() {
+      prompt('Copy this link:', full);
+    });
+  } else {
+    prompt('Copy this link:', full);
+  }
+}
+
+/* ── Code block copy ─────────────────────────────────────── */
+function cbCopy(btn) {
+  // Walk up to .code-block-wrap, then find the <code> element
+  var wrap = btn.closest('.code-block-wrap');
+  if (!wrap) return;
+  var code = wrap.querySelector('pre.code-block code');
+  if (!code) return;
+
+  var text = code.innerText !== undefined ? code.innerText : code.textContent;
+
+  var ok = function() {
+    var orig = btn.innerHTML;
+    btn.classList.add('copied');
+    btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" '
+      + 'stroke-linecap="round" stroke-linejoin="round" width="14" height="14">'
+      + '<polyline points="20 6 9 17 4 12"/></svg><span>Copied!</span>';
+    setTimeout(function() {
+      btn.classList.remove('copied');
+      btn.innerHTML = orig;
+    }, 2000);
+  };
+
+  if (navigator.clipboard && navigator.clipboard.writeText) {
+    navigator.clipboard.writeText(text).then(ok).catch(function() {
+      fallbackCopy(text, ok);
+    });
+  } else {
+    fallbackCopy(text, ok);
+  }
+}
+
+function fallbackCopy(text, cb) {
+  var ta = document.createElement('textarea');
+  ta.value = text;
+  ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0';
+  document.body.appendChild(ta);
+  ta.focus();
+  ta.select();
+  try { document.execCommand('copy'); if (cb) cb(); }
+  catch(e) {}
+  document.body.removeChild(ta);
+}
+
+
+
+/* ── Content security: warn on raw HTML/PHP injection attempt ──── */
+(function () {
+  // Patterns that suggest someone is typing raw code to inject
+  var DANGEROUS = [
+    /<script/i,
+    /<iframe/i,
+    /<object/i,
+    /<embed/i,
+    /<form/i,
+    /<base\s/i,
+    /<link\s/i,
+    /<meta\s/i,
+    /<svg[\s>]/i,
+    /<\?php/i,
+    /<\?=/,
+    /javascript\s*:/i,
+    /vbscript\s*:/i,
+    /on\w+\s*=/i,       // onerror=, onclick=, onload= etc.
+    /data\s*:\s*text\/html/i,
+  ];
+
+  function checkContent(ta) {
+    var val = ta.value;
+    for (var i = 0; i < DANGEROUS.length; i++) {
+      if (DANGEROUS[i].test(val)) {
+        showHtmlWarning(ta);
+        return;
+      }
+    }
+    hideHtmlWarning(ta);
+  }
+
+  function showHtmlWarning(ta) {
+    var id  = 'sec-warn-' + ta.id;
+    if (document.getElementById(id)) return;
+    var box = document.createElement('div');
+    box.id  = id;
+    box.className = 'sec-warning';
+    box.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
+      + 'stroke-linecap="round" stroke-linejoin="round" width="15" height="15">'
+      + '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>'
+      + '<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>'
+      + '<span>Raw HTML/code detected. For security, HTML is stripped on save. '
+      + 'Use ``` code blocks ``` to display code.</span>';
+    ta.parentNode.insertBefore(box, ta.nextSibling);
+  }
+
+  function hideHtmlWarning(ta) {
+    var id  = 'sec-warn-' + ta.id;
+    var el  = document.getElementById(id);
+    if (el) el.remove();
+  }
+
+  function attachTo(taId) {
+    var ta = document.getElementById(taId);
+    if (!ta) return;
+    ta.addEventListener('input', function () { checkContent(ta); });
+    ta.addEventListener('paste', function () {
+      setTimeout(function () { checkContent(ta); }, 10);
+    });
+  }
+
+  // Attach to both editors on page load and after AJAX reply renders
+  document.addEventListener('DOMContentLoaded', function () {
+    attachTo('replyTa');
+  });
+
+  // Expose for dynamic attachment
+  window.attachSecCheck = attachTo;
+})();
+
+/* ── Welcome Guide addon toggle ──────────────────────────── */
+function wgToggle() {
+  var b   = document.querySelector('.wg-body');
+  var btn = document.querySelector('.wg-toggle');
+  if (!b) return;
+  var open = b.style.display === 'none';
+  b.style.display = open ? '' : 'none';
+  if (btn) {
+    btn.setAttribute('aria-expanded', open ? 'true' : 'false');
+    btn.classList.toggle('wg-open', open);
+  }
+}
diff --git a/public/uploads/.htaccess b/public/uploads/.htaccess
new file mode 100644
index 0000000..c7c121e
--- /dev/null
+++ b/public/uploads/.htaccess
@@ -0,0 +1,11 @@
+# Deny PHP/script execution in the uploads directory
+# Images are served directly; no code should run here.
+<FilesMatch "\.(php|php3|php4|php5|php7|phtml|shtml|cgi|pl|py|rb|sh|bash)$">
+    <IfModule mod_authz_core.c>
+        Require all denied
+    </IfModule>
+    <IfModule !mod_authz_core.c>
+        Order Allow,Deny
+        Deny from all
+    </IfModule>
+</FilesMatch>
diff --git a/users/edit.php b/users/edit.php
new file mode 100644
index 0000000..33dfe57
--- /dev/null
+++ b/users/edit.php
@@ -0,0 +1,133 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+must_login();
+$err = $ok = null;
+
+if ($_SERVER['REQUEST_METHOD']==='POST' && csrf_ok()) {
+    $bio            = sanitise(post('bio'));
+    $location       = mb_substr(sanitise(post('location')), 0, 100);
+    $friends_hidden = isset($_POST['friends_hidden']) ? 1 : 0;
+    $avUrl          = $USER['avatar'];
+
+    // Avatar upload
+    if (!empty($_FILES['avatar']['name'])) {
+        $f = $_FILES['avatar'];
+        if ($f['size'] > 2*1024*1024)   { $err = 'Avatar must be under 2 MB.'; }
+        elseif (!in_array($f['type'],['image/jpeg','image/png','image/gif','image/webp'])) { $err='Image files only.'; }
+        else {
+            $ext  = strtolower(pathinfo($f['name'],PATHINFO_EXTENSION));
+            $name = 'av_'.$USER['id'].'_'.time().'.'.$ext;
+            $dir  = UPLOADS.'/avatars/';
+            if (!is_dir($dir)) mkdir($dir,0755,true);
+            if (move_uploaded_file($f['tmp_name'],$dir.$name)) {
+                $avUrl = BASE.'/public/uploads/avatars/'.$name;
+            } else { $err='Upload failed.'; }
+        }
+    }
+
+    if (!$err) {
+        DB::run('UPDATE users SET bio=?,avatar=?,friends_hidden=?,location=? WHERE id=?',
+            [$bio?:null, $avUrl, $friends_hidden, $location, $USER['id']]);
+
+        // Password change
+        $np = post('new_password');
+        if ($np) {
+            if (!password_verify(post('cur_password'), $USER['password'])) { $err='Current password incorrect.'; }
+            elseif (strlen($np)<8) { $err='New password must be 8+ characters.'; }
+            elseif ($np!==post('new_password2')) { $err='New passwords do not match.'; }
+            else {
+                DB::run('UPDATE users SET password=? WHERE id=?',
+                    [password_hash($np,PASSWORD_BCRYPT,['cost'=>12]),$USER['id']]);
+            }
+        }
+        if (!$err) { $ok='Profile updated!'; $USER=current_user(); }
+    }
+}
+
+$PAGE_TITLE = 'Edit Profile';
+include __DIR__ . '/../views/partials/layout.php';
+?>
+<nav class="bc">
+  <a href="<?=u('/')?>">Home</a> ›
+  <a href="<?=u('users/profile.php?u='.urlencode($USER['username']))?>">@<?=e($USER['username'])?></a> ›
+  <span>Edit Profile</span>
+</nav>
+<div class="form-card">
+  <h1>Edit Profile</h1>
+  <?php if($err):?><div class="alert err"><?=e($err)?></div><?php endif;?>
+  <?php if($ok): ?><div class="alert ok"><?=e($ok)?></div><?php endif;?>
+  <form method="POST" enctype="multipart/form-data">
+    <?=csrf_input()?>
+
+    <div class="form-section">
+      <h2>Profile Picture</h2>
+      <div class="av-upload">
+        <?php if($USER['avatar']):?>
+          <img src="<?=e($USER['avatar'])?>" class="av-xl" id="avPrev" alt="">
+        <?php else:?>
+          <span class="av-xl av-init" id="avPrev"><?=strtoupper($USER['username'][0])?></span>
+        <?php endif;?>
+        <div>
+          <label for="avatar" class="btn-ghost" style="cursor:pointer">Choose Image</label>
+          <input type="file" id="avatar" name="avatar" accept="image/*" style="display:none" onchange="prevAv(this)">
+          <p class="hint">Max 2 MB · JPG, PNG, GIF, WebP</p>
+        </div>
+      </div>
+    </div>
+
+    <div class="form-section">
+      <h2>About Me</h2>
+      <div class="fg">
+        <label for="bio">Bio</label>
+        <textarea name="bio" id="bio" class="fi" rows="4" maxlength="500"
+                  placeholder="Tell the community about yourself…"><?=e($USER['bio']??'')?></textarea>
+        <span class="hint"><span id="bioLen"><?=mb_strlen($USER['bio']??'')?></span>/500</span>
+      </div>
+      <div class="fg">
+        <label for="location">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14" style="vertical-align:middle;margin-right:4px"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
+          Location <small class="hint" style="display:inline">(optional)</small>
+        </label>
+        <input type="text" name="location" id="location" class="fi" maxlength="100"
+               value="<?=e($USER['location']??'')?>" placeholder="City, Country">
+        <span class="hint">Shown on your posts and profile</span>
+      </div>
+    </div>
+
+    <div class="form-section">
+      <h2>Privacy</h2>
+      <label class="toggle-label" style="cursor:pointer">
+        <div>
+          <strong>Hide Friends List</strong>
+          <p class="hint">Others won't see your friends list on your profile</p>
+        </div>
+        <label class="toggle-sw">
+          <input type="checkbox" name="friends_hidden" <?=$USER['friends_hidden']?'checked':''?>>
+          <span class="toggle-knob"></span>
+        </label>
+      </label>
+    </div>
+
+    <div class="form-section">
+      <h2>Change Password</h2>
+      <p class="hint">Leave blank to keep your current password.</p>
+      <div class="fg"><label>Current Password</label><input type="password" name="cur_password" class="fi" placeholder="Current password"></div>
+      <div class="fg"><label>New Password</label><input type="password" name="new_password" class="fi" placeholder="Min. 8 characters"></div>
+      <div class="fg"><label>Confirm New Password</label><input type="password" name="new_password2" class="fi" placeholder="Repeat new password"></div>
+    </div>
+
+    <div class="form-actions">
+      <a href="<?=u('users/profile.php?u='.urlencode($USER['username']))?>" class="btn-ghost">Cancel</a>
+      <button type="submit" class="btn-primary">Save Changes</button>
+    </div>
+  </form>
+</div>
+<script>
+function prevAv(i){if(!i.files[0])return;var r=new FileReader();r.onload=function(e){var p=document.getElementById('avPrev');if(p.tagName==='IMG'){p.src=e.target.result;}else{var img=document.createElement('img');img.src=e.target.result;img.className='av-xl';img.id='avPrev';p.replaceWith(img);}};r.readAsDataURL(i.files[0]);}
+document.getElementById('bio').addEventListener('input',function(){document.getElementById('bioLen').textContent=this.value.length;});
+</script>
+
+<style>
+.toggle-label { display:flex; align-items:center; justify-content:space-between; padding:8px 0; }
+</style>
+<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>
diff --git a/users/profile.php b/users/profile.php
new file mode 100644
index 0000000..7537f73
--- /dev/null
+++ b/users/profile.php
@@ -0,0 +1,250 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+$uname   = get('u');
+$profile = DB::row('SELECT * FROM users WHERE username=?', [$uname]);
+if (!$profile) render_404();
+
+$topics = DB::rows("
+    SELECT t.*,c.name AS cat_name,c.slug AS cat_slug
+    FROM topics t JOIN categories c ON c.id=t.category_id
+    WHERE t.user_id=? ORDER BY t.created_at DESC LIMIT 15
+", [$profile['id']]);
+
+$posts = DB::rows("
+    SELECT p.*,t.title AS topic_title,t.slug AS topic_slug
+    FROM posts p JOIN topics t ON t.id=p.topic_id
+    WHERE p.user_id=? AND p.deleted=0 ORDER BY p.created_at DESC LIMIT 15
+", [$profile['id']]);
+
+$showFriends = !$profile['friends_hidden']
+    || ($USER && (int)$USER['id'] === (int)$profile['id'])
+    || ($USER && is_admin());
+
+$friendsList = $showFriends ? friends_list((int)$profile['id']) : [];
+$fStatus     = $USER ? friend_status((int)$USER['id'], (int)$profile['id']) : 'none';
+$kTier       = karma_tier((int)$profile['karma']);
+
+$PAGE_TITLE = '@' . $profile['username'];
+include __DIR__ . '/../views/partials/layout.php';
+?>
+
+<div class="profile-hdr">
+  <!-- Avatar -->
+  <div class="profile-av">
+    <?php if ($profile['avatar']): ?>
+      <img src="<?= e($profile['avatar']) ?>" class="av-xl" alt="">
+    <?php else: ?>
+      <span class="av-xl av-init"><?= strtoupper($profile['username'][0]) ?></span>
+    <?php endif; ?>
+    <!-- Karma tier badge under avatar — green leaf -->
+    <div class="profile-tier-badge profile-tier-badge-green">
+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="11" height="11"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>
+      <?= e($kTier['name']) ?>
+    </div>
+  </div>
+
+  <!-- Info -->
+  <div class="profile-info">
+    <div class="profile-name-row">
+      <h1>@<?= e($profile['username']) ?></h1>
+      <span class="role-tag role-<?= e($profile['role']) ?>"><?= e($profile['role']) ?></span>
+      <?php if ($profile['suspended']): ?>
+        <span class="role-tag role-admin">Suspended</span>
+      <?php endif; ?>
+    </div>
+    <?php if ($profile['bio']): ?>
+      <p class="profile-bio"><?= e($profile['bio']) ?></p>
+    <?php endif; ?>
+    <div class="profile-meta">
+      <span>📅 Joined <span class="ago" data-ts="<?= e($profile['joined_at']) ?>"></span></span>
+      <span>🕐 Last seen <span class="ago" data-ts="<?= e($profile['last_seen']) ?>"></span></span>
+      <?php if (!empty($profile['location'])): ?>
+        <span>
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="13" height="13" style="vertical-align:middle"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
+          <?= e($profile['location']) ?>
+        </span>
+      <?php endif; ?>
+    </div>
+
+    <!-- Karma bar — always green -->
+    <div class="profile-karma-row">
+      <div class="pkr-head">
+        <span class="pkr-icon">
+          <svg viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>
+        </span>
+        <span class="pkr-label pkr-label-green"><?= e($kTier['name']) ?></span>
+        <span class="pkr-pts"><?= number_format((int)$profile['karma']) ?> karma</span>
+      </div>
+      <div class="pkr-bar">
+        <div class="pkr-fill pkr-fill-green" style="width:<?= $kTier['progress'] ?>%"></div>
+      </div>
+      <?php if ($kTier['next'] !== null): ?>
+        <div class="pkr-next">
+          <?= $kTier['progress'] ?>% · <?= number_format($kTier['next'] - (int)$profile['karma']) ?> more to <?= e(karma_tier($kTier['next'])['name']) ?>
+        </div>
+      <?php else: ?>
+        <div class="pkr-next">🏆 Maximum tier reached!</div>
+      <?php endif; ?>
+    </div>
+  </div>
+
+  <!-- Stats -->
+  <div class="profile-stats">
+    <div class="pstat">
+      <strong><?= number_format($profile['post_count']) ?></strong>
+      <span>Posts</span>
+    </div>
+    <div class="pstat">
+      <strong><?= number_format($profile['topic_count']) ?></strong>
+      <span>Topics</span>
+    </div>
+    <div class="pstat karma-stat" style="--kc:<?= e($kTier['color']) ?>">
+      <strong><?= number_format((int)$profile['karma']) ?></strong>
+      <span><?= $kTier['icon'] ?> Karma</span>
+    </div>
+    <div class="pstat">
+      <strong><?= count($friendsList) ?></strong>
+      <span>Friends</span>
+    </div>
+  </div>
+
+  <!-- Actions -->
+  <div class="profile-actions">
+    <?php if ($USER && $USER['username'] === $profile['username']): ?>
+      <a href="<?= u('users/edit.php') ?>" class="btn-ghost btn-sm">✏️ Edit Profile</a>
+      <a href="<?= u('messages/') ?>" class="btn-ghost btn-sm">📬 Messages</a>
+    <?php elseif ($USER): ?>
+      <?php if ($fStatus === 'none'): ?>
+        <button class="btn-primary btn-sm" onclick="friendAction('send',<?= $profile['id'] ?>,this)">+ Add Friend</button>
+      <?php elseif ($fStatus === 'pending_sent'): ?>
+        <button class="btn-ghost btn-sm" onclick="friendAction('cancel',<?= $profile['id'] ?>,this)">✓ Sent (Cancel)</button>
+      <?php elseif ($fStatus === 'pending_received'): ?>
+        <button class="btn-primary btn-sm" onclick="friendAction('accept',<?= $profile['id'] ?>,this)">✓ Accept</button>
+        <button class="btn-ghost btn-sm"   onclick="friendAction('decline',<?= $profile['id'] ?>,this)">✗ Decline</button>
+      <?php elseif ($fStatus === 'friends'): ?>
+        <button class="btn-ghost btn-sm" onclick="friendAction('remove',<?= $profile['id'] ?>,this)">👥 Friends</button>
+      <?php endif; ?>
+      <a href="<?= u('messages/compose.php?to='.urlencode($profile['username'])) ?>" class="btn-ghost btn-sm">✉️ Message</a>
+    <?php endif; ?>
+
+    <?php if ($USER && is_admin() && (int)$USER['id'] !== (int)$profile['id']): ?>
+      <a href="<?= u('admin/user.php?id='.$profile['id']) ?>" class="btn-ghost btn-sm">🛡️ Admin</a>
+    <?php endif; ?>
+  </div>
+</div>
+
+<!-- Pending friend requests (profile owner only) -->
+<?php if ($USER && (int)$USER['id'] === (int)$profile['id']): ?>
+  <?php $pending = pending_requests((int)$USER['id']); ?>
+  <?php if ($pending): ?>
+    <div class="pending-requests">
+      <h3>📥 Friend Requests (<?= count($pending) ?>)</h3>
+      <?php foreach ($pending as $req): ?>
+        <div class="pending-row">
+          <?php if ($req['avatar']): ?>
+            <img src="<?= e($req['avatar']) ?>" class="av-sm" alt="">
+          <?php else: ?>
+            <span class="av-sm av-init"><?= strtoupper($req['username'][0]) ?></span>
+          <?php endif; ?>
+          <a href="<?= u('users/profile.php?u='.urlencode($req['username'])) ?>">@<?= e($req['username']) ?></a>
+          <button class="btn-primary btn-sm" onclick="friendAction('accept',<?= $req['id'] ?>,this)">Accept</button>
+          <button class="btn-ghost btn-sm"   onclick="friendAction('decline',<?= $req['id'] ?>,this)">Decline</button>
+        </div>
+      <?php endforeach; ?>
+    </div>
+  <?php endif; ?>
+<?php endif; ?>
+
+<!-- Tabs -->
+<div class="profile-tabs">
+  <button class="tab-btn active" onclick="showTab('topics',this)">
+    Topics <span class="tab-count"><?= count($topics) ?></span>
+  </button>
+  <button class="tab-btn" onclick="showTab('replies',this)">
+    Replies <span class="tab-count"><?= count($posts) ?></span>
+  </button>
+  <?php if ($showFriends): ?>
+    <button class="tab-btn" onclick="showTab('friends',this)">
+      Friends <span class="tab-count"><?= count($friendsList) ?></span>
+    </button>
+  <?php endif; ?>
+</div>
+
+<!-- Topics tab -->
+<div id="tab-topics" class="tab-pane">
+  <?php foreach ($topics as $t): ?>
+    <div class="topic-row">
+      <div class="tr-body">
+        <a href="<?= u('forum/topic.php?slug='.urlencode($t['slug'])) ?>" class="tr-title"><?= e($t['title']) ?></a>
+        <div class="tr-meta">
+          <a href="<?= u('forum/category.php?slug='.urlencode($t['cat_slug'])) ?>"
+             class="cat-tag" style="--cc:#3b82f6"><?= e($t['cat_name']) ?></a>
+          <span class="ago" data-ts="<?= e($t['created_at']) ?>"></span>
+        </div>
+      </div>
+      <div class="tr-counts"><span>💬 <?= $t['reply_count'] ?></span></div>
+    </div>
+  <?php endforeach; ?>
+  <?php if (!$topics): ?><p class="empty-msg">No topics yet.</p><?php endif; ?>
+</div>
+
+<!-- Replies tab -->
+<div id="tab-replies" class="tab-pane hidden">
+  <?php foreach ($posts as $p): ?>
+    <div class="reply-card">
+      <div class="rc-top">
+        <a href="<?= u('forum/topic.php?slug='.urlencode($p['topic_slug']).'#post-'.$p['id']) ?>">
+          ↩ <?= e($p['topic_title']) ?>
+        </a>
+        <span class="ago" data-ts="<?= e($p['created_at']) ?>"></span>
+      </div>
+      <div class="rc-body md" data-raw="<?= e(base64_encode($p['content'])) ?>"><?= e($p['content']) ?></div>
+    </div>
+  <?php endforeach; ?>
+  <?php if (!$posts): ?><p class="empty-msg">No replies yet.</p><?php endif; ?>
+</div>
+
+<!-- Friends tab -->
+<?php if ($showFriends): ?>
+  <div id="tab-friends" class="tab-pane hidden">
+    <?php if ($friendsList): ?>
+      <div class="friends-grid">
+        <?php foreach ($friendsList as $f): ?>
+          <?php $ft = karma_tier((int)$f['karma']); ?>
+          <a href="<?= u('users/profile.php?u='.urlencode($f['username'])) ?>" class="friend-card">
+            <?php if ($f['avatar']): ?>
+              <img src="<?= e($f['avatar']) ?>" class="av-md" alt="">
+            <?php else: ?>
+              <span class="av-md av-init"><?= strtoupper($f['username'][0]) ?></span>
+            <?php endif; ?>
+            <div class="friend-name">@<?= e($f['username']) ?></div>
+            <div class="friend-karma" style="color:<?= e($ft['color']) ?>"><?= $ft['icon'] ?> <?= number_format((int)$f['karma']) ?></div>
+            <div class="friend-role"><span class="role-tag role-<?= e($f['role']) ?>"><?= e($f['role']) ?></span></div>
+          </a>
+        <?php endforeach; ?>
+      </div>
+    <?php else: ?>
+      <p class="empty-msg">No friends yet.</p>
+    <?php endif; ?>
+  </div>
+<?php endif; ?>
+
+<script>
+function showTab(name,btn){
+  document.querySelectorAll('.tab-pane').forEach(function(p){p.classList.add('hidden');});
+  document.querySelectorAll('.tab-btn').forEach(function(b){b.classList.remove('active');});
+  document.getElementById('tab-'+name).classList.remove('hidden');
+  btn.classList.add('active');
+}
+function friendAction(action,targetId,btn){
+  var fd=new FormData();
+  fd.append('action',action);fd.append('target_id',targetId);fd.append('csrf',NX.csrf);
+  fetch(NX.base+'/api/friend.php',{method:'POST',body:fd})
+    .then(function(r){return r.json();})
+    .then(function(d){
+      if(d.ok){toast(d.msg,'ok');setTimeout(function(){location.reload();},800);}
+      else toast(d.error,'err');
+    });
+}
+</script>
+<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>
diff --git a/users/search.php b/users/search.php
new file mode 100644
index 0000000..966e4d7
--- /dev/null
+++ b/users/search.php
@@ -0,0 +1,85 @@
+<?php
+require_once __DIR__ . '/../includes/bootstrap.php';
+
+$q       = get('q');
+$results = [];
+
+if (mb_strlen($q) >= 2) {
+    $results = DB::rows(
+        "SELECT u.id, u.username, u.avatar, u.role, u.karma, u.post_count, u.topic_count,
+                u.joined_at, u.bio
+         FROM users u
+         WHERE (u.username LIKE ? OR u.bio LIKE ?) AND u.suspended=0
+         ORDER BY u.post_count DESC LIMIT 40",
+        ['%'.$q.'%', '%'.$q.'%']
+    );
+}
+
+$PAGE_TITLE = 'Find Users';
+include __DIR__ . '/../views/partials/layout.php';
+?>
+<div style="max-width:720px">
+  <div class="sec-title" style="margin-bottom:20px">
+    <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
+    Find Users
+  </div>
+
+  <form method="GET" style="display:flex;gap:10px;margin-bottom:24px">
+    <input type="text" name="q" value="<?= e($q) ?>" class="fi" style="flex:1;max-width:500px"
+           placeholder="Search by username or bio…" autofocus autocomplete="off">
+    <button type="submit" class="btn-primary">Search</button>
+  </form>
+
+  <?php if ($q && mb_strlen($q) >= 2): ?>
+    <p style="color:var(--muted);font-size:14px;margin-bottom:16px">
+      <?= count($results) ?> result<?= count($results) !== 1 ? 's' : '' ?> for
+      "<strong><?= e($q) ?></strong>"
+    </p>
+
+    <?php if (empty($results)): ?>
+      <div class="empty-state">
+        <p>No users found matching "<?= e($q) ?>".</p>
+      </div>
+    <?php else: ?>
+      <div class="user-search-grid">
+        <?php foreach ($results as $u):
+          $kt = karma_tier((int)$u['karma']); ?>
+          <a href="<?= u('users/profile.php?u=' . urlencode($u['username'])) ?>" class="user-search-card">
+            <div class="usc-av">
+              <?php if ($u['avatar']): ?>
+                <img src="<?= e($u['avatar']) ?>" class="av-lg" alt="">
+              <?php else: ?>
+                <span class="av-lg av-init"><?= strtoupper($u['username'][0]) ?></span>
+              <?php endif; ?>
+            </div>
+            <div class="usc-body">
+              <div class="usc-name">
+                @<?= e($u['username']) ?>
+                <span class="role-tag role-<?= e($u['role']) ?>"><?= e($u['role']) ?></span>
+              </div>
+              <?php if ($u['bio']): ?>
+                <div class="usc-bio"><?= e(mb_substr($u['bio'], 0, 80)) ?><?= mb_strlen($u['bio']) > 80 ? '…' : '' ?></div>
+              <?php endif; ?>
+              <div class="usc-stats">
+                <span><?= number_format($u['post_count']) ?> posts</span>
+                <span>·</span>
+                <span style="color:<?= e($kt['color']) ?>"><?= $kt['icon'] ?> <?= number_format((int)$u['karma']) ?> karma</span>
+              </div>
+            </div>
+            <?php if ($USER && (int)$USER['id'] !== (int)$u['id']): ?>
+              <div class="usc-actions">
+                <a href="<?= u('messages/chat.php?with=' . urlencode($u['username'])) ?>"
+                   class="btn-ghost btn-sm" onclick="event.preventDefault();event.stopPropagation();window.location=this.href">
+                  💬 Message
+                </a>
+              </div>
+            <?php endif; ?>
+          </a>
+        <?php endforeach; ?>
+      </div>
+    <?php endif; ?>
+  <?php elseif ($q): ?>
+    <div class="alert warn">Please enter at least 2 characters to search.</div>
+  <?php endif; ?>
+</div>
+<?php include __DIR__ . '/../views/partials/layout_end.php'; ?>
diff --git a/views/partials/admin_layout.php b/views/partials/admin_layout.php
new file mode 100644
index 0000000..a380b1d
--- /dev/null
+++ b/views/partials/admin_layout.php
@@ -0,0 +1,42 @@
+<?php
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+  <title><?= e($PAGE_TITLE??'Admin') ?> — Admin — <?= e(cfg('site_name')) ?></title>
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
+  <link rel="stylesheet" href="<?= asset('css/main.css') ?>">
+  <link rel="stylesheet" href="<?= asset('css/admin.css') ?>">
+</head>
+<body class="admin-body">
+<div class="admin-wrap">
+  <aside class="admin-sb">
+    <div class="admin-sb-top">
+      <a href="<?= u('/') ?>" class="admin-logo">
+        <div class="logo-mark"><?= e(substr(cfg('site_name','N'),0,1)) ?></div>
+        <div>
+          <div class="admin-logo-name"><?= e(cfg('site_name','Nexus Forum')) ?></div>
+          <div class="admin-logo-sub">Admin Panel</div>
+        </div>
+      </a>
+    </div>
+    <nav class="admin-nav">
+      <?php $p = $ADMIN_PAGE ?? ''; ?>
+      <a href="<?= u('admin/') ?>"               class="anav <?= $p==='dashboard' ?'on':'' ?>">📊 Dashboard</a>
+      <a href="<?= u('admin/users.php') ?>"       class="anav <?= $p==='users'     ?'on':'' ?>">👥 Users</a>
+      <a href="<?= u('admin/categories.php') ?>"  class="anav <?= $p==='categories'?'on':'' ?>">📂 Categories</a>
+      <a href="<?= u('admin/topics.php') ?>"      class="anav <?= $p==='topics'    ?'on':'' ?>">💬 Topics</a>
+      <a href="<?= u('admin/themes.php') ?>"      class="anav <?= $p==='themes'    ?'on':'' ?>">🎨 Themes</a>
+      <a href="<?= u('admin/settings.php') ?>"    class="anav <?= $p==='settings'  ?'on':'' ?>">⚙️ Settings</a>
+      <a href="<?= u('admin/addons.php') ?>" class="anav <?= $p==='addons'?'on':'' ?>">🧩 Addons</a>
+      <div class="anav-div"></div>
+      <a href="<?= u('/') ?>" class="anav">🏠 Back to Forum</a>
+    </nav>
+  </aside>
+  <div class="admin-main">
+    <header class="admin-topbar">
+      <h1><?= e($PAGE_TITLE??'Admin') ?></h1>
+      <span>@<?= e($USER['username']) ?></span>
+    </header>
+    <div class="admin-content">
diff --git a/views/partials/admin_layout_end.php b/views/partials/admin_layout_end.php
new file mode 100644
index 0000000..bdfc8eb
--- /dev/null
+++ b/views/partials/admin_layout_end.php
@@ -0,0 +1,8 @@
+    </div><!-- /.admin-content -->
+  </div><!-- /.admin-main -->
+</div><!-- /.admin-wrap -->
+<script>
+var NX={base:'<?= BASE ?>',csrf:'<?= csrf() ?>',user:<?= json_encode(['id'=>(int)$USER['id'],'name'=>$USER['username'],'role'=>$USER['role']]) ?>};
+</script>
+<script src="<?= asset('js/app.js') ?>"></script>
+</body></html>
diff --git a/views/partials/layout.php b/views/partials/layout.php
new file mode 100644
index 0000000..61a0d36
--- /dev/null
+++ b/views/partials/layout.php
@@ -0,0 +1,139 @@
+<?php
+$_title  = isset($PAGE_TITLE) ? e($PAGE_TITLE) . ' — ' : '';
+$_site   = e(cfg('site_name', 'Nexus Forum'));
+$_unread = unread_count();
+$_msgs   = unread_messages();
+$_cats   = nav_categories();
+?>
+<!DOCTYPE html>
+<html lang="en">
+<script>
+(function(){
+  try{
+    var t=localStorage.getItem('nexus-theme');
+    var p=window.matchMedia&&window.matchMedia('(prefers-color-scheme:dark)').matches;
+    if(t==='dark'||(t===null&&p)) document.documentElement.setAttribute('data-theme','dark');
+  }catch(e){}
+})();
+</script>
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title><?= $_title . $_site ?></title>
+  <link rel="preconnect" href="https://fonts.googleapis.com">
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
+  <link rel="stylesheet" href="<?= asset('css/main.css') ?>">
+  <?= active_theme_css() ?>
+  <?= cfg('custom_head') ?>
+<?php /* ── before_page_head addon hook (collector) ── */
+echo addon_collect('before_page_head');
+?>
+</head>
+<body>
+<header class="site-header">
+  <div class="hdr-inner">
+    <div class="hdr-left">
+      <button class="burger" id="burgerBtn"><span></span><span></span><span></span></button>
+      <a href="<?= u('/') ?>" class="logo">
+        <?php if (cfg('logo_url')): ?>
+          <img src="<?= e(cfg('logo_url')) ?>" alt="<?= $_site ?>" class="logo-img">
+        <?php else: ?>
+          <div class="logo-mark"><?= e(substr(cfg('site_name','N'),0,1)) ?></div>
+        <?php endif; ?>
+        <span class="logo-name"><?= $_site ?></span>
+      </a>
+    </div>
+
+    <div class="hdr-search">
+      <div class="search-wrap">
+        <svg class="srch-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8.5" cy="8.5" r="5.5"/><path d="m13.5 13.5 3 3"/></svg>
+        <input type="text" id="searchInput" placeholder="Search topics…" autocomplete="off">
+        <div class="search-results" id="searchResults"></div>
+      </div>
+    </div>
+
+    <div class="hdr-right">
+      <?php if ($USER): ?>
+
+        <!-- Messages icon -->
+        <a href="<?= u('messages/') ?>" class="icon-btn" title="Messages" style="position:relative">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
+          <?php if ($_msgs > 0): ?>
+            <span class="badge-dot"><?= $_msgs > 9 ? '9+' : $_msgs ?></span>
+          <?php endif; ?>
+        </a>
+
+        <!-- Notifications -->
+        <!-- Dark mode toggle -->
+        <button class="icon-btn" id="themeToggle" onclick="toggleTheme()" title="Toggle dark mode" aria-label="Toggle dark mode">
+          <svg class="theme-icon-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
+          <svg class="theme-icon-dark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
+        </button>
+        <div class="notif-wrap" id="notifWrap">
+          <button class="icon-btn" id="notifBtn" title="Notifications">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
+            <?php if ($_unread > 0): ?>
+              <span class="badge-dot"><?= $_unread > 9 ? '9+' : $_unread ?></span>
+            <?php endif; ?>
+          </button>
+          <div class="notif-drop" id="notifDrop">
+            <div class="notif-head"><span>Notifications</span><button id="readAllBtn" class="link-btn">Mark all read</button></div>
+            <div id="notifList"><p class="notif-empty">Loading…</p></div>
+          </div>
+        </div>
+
+        <!-- User menu -->
+        <div class="user-wrap" id="userWrap">
+          <button class="avatar-btn" id="userBtn">
+            <?php if ($USER['avatar']): ?>
+              <img src="<?= e($USER['avatar']) ?>" class="av-sm" alt="">
+            <?php else: ?>
+              <span class="av-sm av-init"><?= strtoupper($USER['username'][0]) ?></span>
+            <?php endif; ?>
+          </button>
+          <div class="user-drop" id="userDrop">
+            <div class="user-drop-top">
+              <strong>@<?= e($USER['username']) ?></strong>
+              <span class="role-tag role-<?= e($USER['role']) ?>"><?= e($USER['role']) ?></span>
+            </div>
+            <a href="<?= u('users/profile.php?u='.urlencode($USER['username'])) ?>">👤 My Profile</a>
+            <a href="<?= u('messages/') ?>">📬 Messages <?php if ($_msgs>0):?><span class="badge-dot" style="position:static;margin-left:4px"><?=$_msgs?></span><?php endif;?></a>
+            <a href="<?= u('users/edit.php') ?>">✏️ Edit Profile</a>
+            <?php if (is_admin()): ?>
+              <a href="<?= u('admin/') ?>" class="admin-lnk">🛡️ Admin Panel</a>
+            <?php endif; ?>
+            <div class="drop-div"></div>
+            <a href="<?= u('auth/logout.php') ?>" class="logout-lnk">🚪 Log Out</a>
+          </div>
+        </div>
+
+      <?php else: ?>
+        <a href="<?= u('auth/login.php') ?>" class="btn-ghost">Log In</a>
+        <a href="<?= u('auth/register.php') ?>" class="btn-primary">Sign Up</a>
+      <?php endif; ?>
+    </div>
+  </div>
+</header>
+
+<div class="sb-overlay" id="sbOverlay"></div>
+<aside class="sidebar" id="sidebar">
+  <nav>
+    <a href="<?= u('/') ?>" class="nav-link">🏠 Home</a>
+    <?php if ($USER): ?>
+      <a href="<?= u('forum/new-topic.php') ?>" class="nav-link new-link">+ New Topic</a>
+      <a href="<?= u('messages/') ?>" class="nav-link">📬 Messages<?php if($_msgs>0):?> <span style="background:#ef4444;color:#fff;border-radius:10px;padding:1px 6px;font-size:10px;font-weight:700"><?=$_msgs?></span><?php endif;?></a>
+    <?php endif; ?>
+    <div class="nav-sep">Categories</div>
+    <?php foreach ($_cats as $c): ?>
+      <a href="<?= u('forum/category.php?slug='.urlencode($c['slug'])) ?>" class="nav-link">
+        <span class="cat-dot" style="background:<?= e($c['color']) ?>"></span>
+        <?= e($c['icon']) ?> <?= e($c['name']) ?>
+      </a>
+    <?php endforeach; ?>
+    <div class="nav-sep">More</div>
+    <a href="<?= u('forum/search.php') ?>" class="nav-link">🔍 Search</a>
+  </nav>
+</aside>
+
+<main class="main"><div class="wrap">
diff --git a/views/partials/layout_end.php b/views/partials/layout_end.php
new file mode 100644
index 0000000..a5b5868
--- /dev/null
+++ b/views/partials/layout_end.php
@@ -0,0 +1,95 @@
+  </div><!-- /.wrap -->
+</main>
+
+<?php $stats = forum_stats(); ?>
+<footer class="site-footer">
+  <div class="footer-inner">
+    <div class="footer-stats">
+      <span title="Topics">💬 <strong><?= number_format($stats['topics']) ?></strong> topics</span>
+      <span class="fs-div">·</span>
+      <span title="Posts">📝 <strong><?= number_format($stats['posts']) ?></strong> posts</span>
+      <span class="fs-div">·</span>
+      <span title="Members">👥 <strong><?= number_format($stats['users']) ?></strong> members</span>
+      <span class="fs-div">·</span>
+      <span title="Online now">🟢 <strong><?= $stats['online'] ?></strong> online</span>
+      <?php if ($stats['newest']): ?>
+        <span class="fs-div">·</span>
+        <span>Newest: <a href="<?= u('users/profile.php?u='.urlencode($stats['newest']['username'])) ?>">@<?= e($stats['newest']['username']) ?></a></span>
+      <?php endif; ?>
+    </div>
+    <div class="footer-right">
+      <span><?= e(cfg('site_name','Nexus Forum')) ?></span>
+      <?php if ($USER && is_admin()): ?>
+        <a href="<?= u('admin/') ?>">Admin</a>
+      <?php endif; ?>
+    </div>
+  </div>
+</footer>
+
+<script>
+var NX = {
+  base:        '<?= BASE ?>',
+  csrf:        '<?= csrf() ?>',
+  maxUploadMb: <?= (int)(cfg('max_upload_mb', '5')) ?>,
+  user:        <?= $USER ? json_encode(['id'=>(int)$USER['id'],'name'=>$USER['username'],'role'=>$USER['role']]) : 'null' ?>
+};
+</script>
+<!-- Prism.js syntax tokenizer — loaded lazily only when a code block with a language exists -->
+<!-- We use our own token color theme from main.css, NOT Prism's default theme CSS -->
+<script>
+(function(){
+  if (!document.querySelector('.code-block-wrap pre.code-block code[class]')) return;
+  // Load Prism core + autoloader (no theme CSS — we provide our own via main.css)
+  var s = document.createElement('script');
+  s.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js';
+  s.onload = function() {
+    var a = document.createElement('script');
+    a.src = 'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js';
+    a.onload = function() {
+      // Autoloader path for language components
+      if (Prism.plugins && Prism.plugins.autoloader) {
+        Prism.plugins.autoloader.languages_path =
+          'https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/';
+      }
+      Prism.highlightAll();
+    };
+    document.body.appendChild(a);
+  };
+  document.body.appendChild(s);
+})();
+</script>
+<script src="<?= asset('js/app.js') ?>"></script>
+
+<script>
+/* ── Dark mode ────────────────────────────────────────────── */
+(function(){
+  var stored = localStorage.getItem('nexus-theme');
+  var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme:dark)').matches;
+  var dark = stored ? stored === 'dark' : prefersDark;
+  if (dark) document.documentElement.setAttribute('data-theme','dark');
+  updateThemeIcons(dark);
+})();
+
+function toggleTheme(){
+  var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
+  var newDark = !isDark;
+  if (newDark) {
+    document.documentElement.setAttribute('data-theme','dark');
+    localStorage.setItem('nexus-theme','dark');
+  } else {
+    document.documentElement.removeAttribute('data-theme');
+    localStorage.setItem('nexus-theme','light');
+  }
+  updateThemeIcons(newDark);
+}
+
+function updateThemeIcons(dark){
+  var light = document.querySelector('.theme-icon-light');
+  var moon  = document.querySelector('.theme-icon-dark');
+  if (light) light.style.display = dark ? 'none' : '';
+  if (moon)  moon.style.display  = dark ? '' : 'none';
+}
+</script>
+
+</body>
+</html>