foxygit / infodisplay Log in
commit c486aebafe5fd4bf40e7f7e1ea1d927fd3898130
Author:     Jens Kristoffersson <jens.se@icloud.com>
AuthorDate: Sat Apr 4 09:32:36 2026 +0200
Commit:     Jens Kristoffersson <jens.se@icloud.com>
CommitDate: Sat Apr 4 09:32:36 2026 +0200

    Initial commit — kiosk display system

    Flask+Sway kiosk med schema, ticker, klocka overlay, livechat,
    WiFi-hantering och sovläge. Styrs via webbaserad adminpanel.

    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 .gitignore                        |   30 +
 install.sh                        |  100 +++
 kiosk-admin-spec.md               |  258 +++++++
 kiosk-display.service             |   28 +
 kiosk-server.service              |   17 +
 kiosk/.gitignore                  |   17 +
 kiosk/README.md                   |  254 ++++++
 kiosk/START.md                    |   28 +
 kiosk/app.py                      | 1527 +++++++++++++++++++++++++++++++++++++
 kiosk/db.py                       |  158 ++++
 kiosk/hdmi-monitor.sh             |   14 +
 kiosk/hdmi-wait.sh                |   21 +
 kiosk/kiosk-display.service       |   23 +
 kiosk/kiosk-launcher.sh           |   72 ++
 kiosk/plans.md                    |    0
 kiosk/scheduler.py                |   85 +++
 kiosk/start.sh                    |  121 +++
 kiosk/static/admin.js             | 1108 +++++++++++++++++++++++++++
 kiosk/static/display.js           |  599 +++++++++++++++
 kiosk/static/qrcode.min.js        |    1 +
 kiosk/static/style.css            |  359 +++++++++
 kiosk/sway-apps.sh                |   65 ++
 kiosk/sway.conf                   |   37 +
 kiosk/templates/admin.html        |  587 ++++++++++++++
 kiosk/templates/chat_display.html |  258 +++++++
 kiosk/templates/chat_mobile.html  |  667 ++++++++++++++++
 kiosk/templates/display.html      |  197 +++++
 kiosk/templates/login.html        |   36 +
 start.sh                          |   78 ++
 sudoers-kiosk                     |    1 +
 30 files changed, 6746 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c21d941
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,30 @@
+# Databas och körningsstate
+kiosk/kiosk.db
+kiosk/kiosk.db-shm
+kiosk/kiosk.db-wal
+
+# Maskinkonfiguration (skärm, ljud — maskinspecifik)
+kiosk/kiosk.conf
+kiosk.conf
+
+# Chattanvändare (personuppgifter)
+kiosk/chat_users.json
+
+# Uppladdade mediefiler
+kiosk/uploads/
+
+# Chat-avatarer
+kiosk/static/chat_avatars/
+
+# Socket.IO-bibliotek (laddas ned av install.sh)
+kiosk/static/socket.io.min.js
+
+# Python
+kiosk/__pycache__/
+*.pyc
+*.pyo
+
+# Temporära filer
+*.swp
+*.swo
+.DS_Store
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..d434623
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,100 @@
+#!/bin/bash
+# install.sh — Installerar och konfigurerar kiosken på Debian 13 (Wayland/Sway)
+set -e
+
+KIOSK_USER="${KIOSK_USER:-mrfox}"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+echo "=== Kiosk — installation ==="
+echo "Användare: $KIOSK_USER"
+echo "Katalog:   $SCRIPT_DIR"
+echo ""
+
+# ── 1. Systempaket ────────────────────────────────────────────────────────────
+echo "[1/7] Installerar systempaket..."
+apt-get update -q
+apt-get install -y \
+  sway chromium curl \
+  iw wpasupplicant \
+  python3 python3-flask python3-flask-socketio \
+  python3-apscheduler python3-psutil python3-eventlet \
+  pipewire pipewire-audio
+
+# ── 2. Socket.IO-klientbibliotek ──────────────────────────────────────────────
+echo "[2/7] Kontrollerar socket.io.min.js..."
+SOCK_JS="$SCRIPT_DIR/kiosk/static/socket.io.min.js"
+if [ ! -f "$SOCK_JS" ]; then
+  echo "  Laddar ner socket.io v4.7.5..."
+  curl -fsSL \
+    "https://cdn.jsdelivr.net/npm/socket.io@4.7.5/client-dist/socket.io.min.js" \
+    -o "$SOCK_JS"
+  echo "  Sparad till $SOCK_JS"
+else
+  echo "  Redan nedladdad."
+fi
+
+# ── 3. Katalog och rättigheter ────────────────────────────────────────────────
+echo "[3/7] Skapar kataloger och sätter rättigheter..."
+mkdir -p "$SCRIPT_DIR/kiosk/uploads"
+chmod +x \
+  "$SCRIPT_DIR/kiosk/start.sh" \
+  "$SCRIPT_DIR/kiosk/kiosk-launcher.sh" \
+  "$SCRIPT_DIR/kiosk/sway-apps.sh" \
+  "$SCRIPT_DIR/kiosk/hdmi-monitor.sh" \
+  "$SCRIPT_DIR/kiosk/hdmi-wait.sh"
+
+# ── 4. Wi-Fi sudoers ──────────────────────────────────────────────────────────
+echo "[4/7] Konfigurerar sudoers för Wi-Fi (wpa_cli)..."
+SUDOERS_FILE="/etc/sudoers.d/kiosk-wifi"
+if [ ! -f "$SUDOERS_FILE" ]; then
+  echo "$KIOSK_USER ALL=(ALL) NOPASSWD: /usr/sbin/wpa_cli" > "$SUDOERS_FILE"
+  chmod 440 "$SUDOERS_FILE"
+  echo "  Skapad: $SUDOERS_FILE"
+else
+  echo "  Redan konfigurerad."
+fi
+
+# ── 5. Autoinloggning på tty1 ─────────────────────────────────────────────────
+echo "[5/7] Konfigurerar autoinloggning på tty1 för $KIOSK_USER..."
+GETTY_OVERRIDE="/etc/systemd/system/getty@tty1.service.d/autologin.conf"
+mkdir -p "$(dirname "$GETTY_OVERRIDE")"
+if [ ! -f "$GETTY_OVERRIDE" ]; then
+  cat > "$GETTY_OVERRIDE" <<EOF
+[Service]
+ExecStart=
+ExecStart=-/sbin/agetty --autologin $KIOSK_USER --noclear %I \$TERM
+EOF
+  systemctl daemon-reload
+  echo "  Autoinloggning aktiverad."
+else
+  echo "  Redan konfigurerad."
+fi
+
+# ── 6. .bash_profile — starta kiosken vid tty1-inloggning ────────────────────
+echo "[6/7] Konfigurerar .bash_profile..."
+BASHPROFILE="/home/$KIOSK_USER/.bash_profile"
+LAUNCHER_LINE="[[ -z \$DISPLAY && \$(tty) == /dev/tty1 ]] && exec bash $SCRIPT_DIR/kiosk/kiosk-launcher.sh"
+if ! grep -qF "kiosk-launcher.sh" "$BASHPROFILE" 2>/dev/null; then
+  echo "" >> "$BASHPROFILE"
+  echo "$LAUNCHER_LINE" >> "$BASHPROFILE"
+  chown "$KIOSK_USER:$KIOSK_USER" "$BASHPROFILE"
+  echo "  Lagt till i $BASHPROFILE"
+else
+  echo "  Redan konfigurerad."
+fi
+
+# ── 7. Klar ───────────────────────────────────────────────────────────────────
+echo ""
+echo "[7/7] Installation klar!"
+echo ""
+echo "Nästa steg:"
+echo "  1. Redigera $SCRIPT_DIR/kiosk/start.sh och sätt KIOSK_USER / KIOSK_PASS"
+echo "  2. Anslut HDMI-skärm"
+echo "  3. Starta om datorn — kiosken startar automatiskt:"
+echo "       sudo reboot"
+echo ""
+echo "Manuell start (utan omstart):"
+echo "  bash $SCRIPT_DIR/kiosk/kiosk-launcher.sh"
+echo ""
+echo "Admin-panel:"
+echo "  http://<IP>:5000/admin"
diff --git a/kiosk-admin-spec.md b/kiosk-admin-spec.md
new file mode 100644
index 0000000..d02b791
--- /dev/null
+++ b/kiosk-admin-spec.md
@@ -0,0 +1,258 @@
+# Kiosk Admin Panel — Specifikation & Nuläge
+
+## Systembeskrivning
+
+Lokalt hostad kiosk på x86 Debian 13-maskin.
+Chromium kör fullskärm via Cage (Wayland) och visar en lokal Flask-app.
+Admin-panel nås från valfri enhet på lokalt nätverk.
+Innehållsbyte sker via WebSocket — ingen omladdning, inget flicker.
+
+---
+
+## Maskin & miljö
+
+| Egenskap | Värde |
+|----------|-------|
+| OS | Debian 13 (trixie), x86 |
+| Användare | `mrfox` (uid=1000) |
+| Projektkatalog | `/home/mrfox/codes/display/` |
+| Compositor | Cage (Wayland), `-m last` (väljer sist ansluten skärm) |
+| Skärmar | `eDP-1` (inbyggd), `HDMI-A-1` (extern, används) |
+| IP (LAN) | `192.168.0.83` |
+| Port | `5000` |
+
+---
+
+## Projektstruktur
+
+```
+display/
+├── start.sh                    ← Huvudskript. Startar Flask + Cage i loop.
+├── install.sh                  ← Installerar paket + systemd-tjänster
+├── kiosk-server.service        ← systemd: Flask-server
+├── kiosk-display.service       ← systemd: Cage + Chromium
+├── sudoers-kiosk               ← Sudoers-regel för reboot utan lösenord
+└── kiosk/
+    ├── app.py                  ← Flask-app, routes, SocketIO, auth, system-API
+    ├── db.py                   ← SQLite CRUD (jobs + state)
+    ├── scheduler.py            ← APScheduler, reload_jobs()
+    ├── kiosk.db                ← SQLite-databas (auto-skapas)
+    ├── templates/
+    │   ├── display.html        ← Fullskärmssida (laddas en gång av Chromium)
+    │   ├── admin.html          ← Admin-panel (svenska)
+    │   └── login.html          ← Inloggningsformulär
+    └── static/
+        ├── display.js          ← WebSocket-klient för display
+        ├── admin.js            ← Admin-logik (fetch + SocketIO)
+        └── style.css           ← Delade stilar (mörkt tema)
+```
+
+---
+
+## Starta systemet
+
+```bash
+bash /home/mrfox/codes/display/start.sh
+```
+
+`start.sh` kör i en loop: om Cage eller Flask kraschar startar den om automatiskt.
+PIDs sparas till `/tmp/kiosk.pids` (format: `FLASK_PID CAGE_PID`) — används av `/api/restart`.
+
+### Autologin + autostart vid boot
+
+```bash
+# Getty autologin på tty1
+sudo mkdir -p /etc/systemd/system/getty@tty1.service.d/
+sudo tee /etc/systemd/system/getty@tty1.service.d/autologin.conf << 'EOF'
+[Service]
+ExecStart=
+ExecStart=-/sbin/agetty --autologin mrfox --noclear %I $TERM
+EOF
+
+# Autostart via .bash_profile
+echo '[[ -z $DISPLAY && $(tty) == /dev/tty1 ]] && exec bash /home/mrfox/codes/display/start.sh' >> ~/.bash_profile
+```
+
+---
+
+## Miljövariabler (start.sh + kiosk-server.service)
+
+| Variabel | Värde |
+|----------|-------|
+| `KIOSK_USER` | `mrfox` |
+| `KIOSK_PASS` | `qweasdzxc` |
+| `SECRET_KEY` | `kiosk-hemlig-nyckel-byt-garna` |
+
+**VIKTIGT:** `SECRET_KEY` måste vara fast och identisk i alla instanser. Annars ogiltigförklaras sessioner vid omstart.
+
+---
+
+## Autentisering
+
+Ersatte HTTP Basic Auth med sessionsbaserat inloggningsformulär (fungerar i alla webbläsare utan HTTPS).
+
+- `GET /login` — visar formulär
+- `POST /login` — validerar mot `KIOSK_USER`/`KIOSK_PASS`, sätter `session['logged_in']`
+- `GET /logout` — rensar session
+- `login_required`-decorator skyddar `/admin` och alla `/api/*`-routes
+- `/display` kräver ingen auth
+
+---
+
+## Flask Routes
+
+| Method | Route | Auth | Beskrivning |
+|--------|-------|------|-------------|
+| GET | `/display` | Nej | Fullskärmssida för Chromium |
+| GET/POST | `/login` | Nej | Inloggning |
+| GET | `/logout` | Nej | Logga ut |
+| GET | `/admin` | Ja | Admin-panel |
+| GET | `/api/sysinfo` | Ja | CPU, RAM, disk, uptime, aktiv källa |
+| GET | `/api/jobs` | Ja | Lista alla schemalagda jobb |
+| POST | `/api/jobs` | Ja | Lägg till jobb |
+| DELETE | `/api/jobs/<id>` | Ja | Ta bort jobb |
+| PATCH | `/api/jobs/<id>` | Ja | Toggla aktivt/inaktivt |
+| POST | `/api/restart` | Ja | Starta om kiosk-programmet |
+| POST | `/api/reboot` | Ja | Starta om hela datorn (`sudo reboot`) |
+
+---
+
+## WebSocket
+
+### Server → Display
+
+```python
+socketio.emit('switch', { 'type': 'url'|'video'|'local', 'source': '...' })
+```
+
+### Admin → Server
+
+```python
+# Event: admin_switch
+# data = { type, source }
+# Sparar till DB + forwardar till display
+```
+
+### Reconnect-hantering
+
+Vid WebSocket-reconnect (t.ex. efter Chromium-reload) re-emittar servern senaste state från DB automatiskt via `on_connect`.
+
+---
+
+## Display-lager
+
+Tre lager i `display.html`, bara ett synligt åt gången:
+
+| Lager | ID | Användes för |
+|-------|----|--------------|
+| iframe | `layer-iframe` | URL-visning |
+| video | `layer-video` | Videofiler |
+| local | `layer-local` | Lokal HTML-injektion |
+
+Muspekaren är dold via `cursor: none !important` på alla element.
+
+---
+
+## Chromium-flaggor (start.sh)
+
+```bash
+cage -m last -- chromium \
+  --kiosk \
+  --no-sandbox \
+  --disable-gpu \
+  --disable-web-security \
+  --user-data-dir=/tmp/chromium-kiosk \
+  --disable-infobars \
+  --disable-session-crashed-bubble \
+  --disable-translate \
+  --no-first-run \
+  --ozone-platform=wayland \
+  http://localhost:5000/display
+```
+
+- `--disable-web-security` krävs för att visa sidor som blockerar iframe-inbäddning (X-Frame-Options), t.ex. Västtrafik avgångstavla
+- `--disable-gpu` + `--no-sandbox` krävs för att Chromium ska starta i kiosk-miljön
+- `-m last` i cage väljer sist ansluten skärm (HDMI när inkopplad)
+
+---
+
+## Admin-panel — sektioner
+
+1. **Visa nu** — URL-input, video-input, Töm skärm, Ladda om display
+2. **Schema** — tabell med jobb, formulär för att lägga till
+3. **System** — "Starta om programmet" (dödar cage, start.sh loopar), "Starta om datorn"
+4. **Systeminfo** — CPU/RAM/Disk/Uptime/Aktiv källa, auto-refresh var 10s
+
+### Felhantering i admin.js
+
+Alla `fetch()`-anrop går via `apiFetch()` som:
+- Detekterar redirect till `/login` (ogiltig session) → omdirigerar automatiskt
+- Kastar fel vid `!res.ok`
+
+---
+
+## SQLite-schema
+
+```sql
+CREATE TABLE jobs (
+    id      INTEGER PRIMARY KEY AUTOINCREMENT,
+    label   TEXT NOT NULL,
+    time    TEXT NOT NULL,    -- "HH:MM" 24h
+    type    TEXT NOT NULL,    -- "url", "video", "local"
+    source  TEXT NOT NULL,
+    enabled INTEGER DEFAULT 1
+);
+
+CREATE TABLE state (
+    key   TEXT PRIMARY KEY,
+    value TEXT
+    -- Keys: current_type, current_source
+);
+```
+
+---
+
+## Installerade Python-paket (via apt)
+
+```
+python3-flask
+python3-flask-socketio
+python3-flask-httpauth      (installerat men ej längre använt — kan tas bort)
+python3-apscheduler
+python3-psutil
+python3-eventlet
+```
+
+`pip` saknas på systemet — använd `apt-get install python3-<paket>`.
+
+---
+
+## Sudoers — reboot utan lösenord
+
+```bash
+sudo cp /home/mrfox/codes/display/sudoers-kiosk /etc/sudoers.d/kiosk
+sudo chmod 440 /etc/sudoers.d/kiosk
+# Innehåll: mrfox ALL=(ALL) NOPASSWD: /sbin/reboot, /usr/sbin/reboot
+```
+
+---
+
+## Kända problem / TODO
+
+- `flask-httpauth` är installerat men används inte längre (ersatt av sessionsauth) — kan rensas bort
+- `import signal` finns i app.py men används inte
+- Iframe fungerar inte för sidor med `X-Frame-Options` utan `--disable-web-security` i Chromium
+- Systemd-tjänsterna (`kiosk-server`, `kiosk-display`) är installerade men **ska vara inaktiverade** — `start.sh` via `.bash_profile` används istället för att undvika dubbla Flask-instanser
+  ```bash
+  sudo systemctl disable kiosk-server kiosk-display
+  sudo systemctl stop kiosk-server kiosk-display
+  ```
+- `/api/restart` dödar cage (PID från `/tmp/kiosk.pids`) — fungerar bara när start.sh kör (inte via systemd)
+
+---
+
+## socket.io
+
+Serveras lokalt av Flask-SocketIO på `/socket.io/socket.io.js`.
+Används i både `display.html` och `admin.html`.
+**Inte** CDN — fungerar offline.
diff --git a/kiosk-display.service b/kiosk-display.service
new file mode 100644
index 0000000..07175b8
--- /dev/null
+++ b/kiosk-display.service
@@ -0,0 +1,28 @@
+[Unit]
+Description=Kiosk Display (Cage + Chromium)
+After=kiosk-server.service
+Requires=kiosk-server.service
+
+[Service]
+User=mrfox
+Group=mrfox
+PAMName=login
+Environment=XDG_RUNTIME_DIR=/run/user/1000
+TTYPath=/dev/tty1
+StandardInput=tty
+ExecStartPre=/bin/bash -c "until curl -sf -o /dev/null http://127.0.0.1:5000/display; do sleep 1; done"
+ExecStart=/usr/bin/cage -- \
+  /usr/bin/chromium \
+  --kiosk \
+  --noerr \
+  --disable-infobars \
+  --disable-session-crashed-bubble \
+  --disable-features=Translate \
+  --no-first-run \
+  --ozone-platform=wayland \
+  http://localhost:5000/display
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/kiosk-server.service b/kiosk-server.service
new file mode 100644
index 0000000..3530a8a
--- /dev/null
+++ b/kiosk-server.service
@@ -0,0 +1,17 @@
+[Unit]
+Description=Kiosk Flask Server
+After=network.target
+
+[Service]
+User=mrfox
+WorkingDirectory=/home/mrfox/codes/display/kiosk
+ExecStart=/usr/bin/python3 app.py
+Restart=on-failure
+RestartSec=3
+Environment=KIOSK_USER=mrfox
+Environment=KIOSK_PASS=qweasdzxc
+Environment=SECRET_KEY=kiosk-hemlig-nyckel-byt-garna
+Environment=XDG_RUNTIME_DIR=/run/user/1000
+
+[Install]
+WantedBy=multi-user.target
diff --git a/kiosk/.gitignore b/kiosk/.gitignore
new file mode 100644
index 0000000..84afa8c
--- /dev/null
+++ b/kiosk/.gitignore
@@ -0,0 +1,17 @@
+__pycache__/
+*.py[cod]
+.venv/
+venv/
+env/
+*.sqlite3
+kiosk.db
+kiosk.conf
+uploads/
+node_modules/
+.DS_Store
+.env
+*.log
+*.pid
+.vscode/
+/.cache/
+*.pyc
\ No newline at end of file
diff --git a/kiosk/README.md b/kiosk/README.md
new file mode 100644
index 0000000..67c4cf7
--- /dev/null
+++ b/kiosk/README.md
@@ -0,0 +1,254 @@
+# Kiosk
+
+Digitalt skyltfönster / displayhanterare för Linux (Debian 13). Visar webbsidor, YouTube, bilder, video och lokala HTML-filer i helskärm via Chromium under Sway (Wayland). Styrs via en webbaserad adminpanel.
+
+---
+
+## Installation
+
+```bash
+sudo bash install.sh
+sudo reboot
+```
+
+Kiosken startar automatiskt vid omstart via autoinloggning på tty1.
+
+### Krav
+
+| Paket | Syfte |
+|---|---|
+| `sway` | Wayland-compositor |
+| `chromium` | Kiosk-webbläsare |
+| `python3-flask`, `python3-flask-socketio` | Webbserver |
+| `python3-apscheduler` | Schemaläggare |
+| `python3-psutil`, `python3-eventlet` | Systeminfo, async |
+| `iw`, `wpasupplicant` | Wi-Fi-hantering |
+| `curl` | Nätverkstest vid uppstart |
+
+---
+
+## Adminpanel
+
+```
+http://<maskinens-IP>:5000/admin
+```
+
+Standardinloggning — ändra i `kiosk/start.sh`:
+
+| Variabel | Standardvärde |
+|---|---|
+| `KIOSK_USER` | `mrfox` |
+| `KIOSK_PASS` | `qweasdzxc` |
+| `SECRET_KEY` | `kiosk-hemlig-nyckel-byt-garna` |
+
+---
+
+## Funktioner
+
+### Visa nu
+Visa något direkt utan att det läggs till i schemat:
+- **URL** — valfri webbadress, YouTube-länk konverteras automatiskt till embed
+- **Ladda upp fil** — video, bild, HTML, PDF/presentation
+- **Lokal fil** — bläddra bland filer på servern
+- **Töm skärm** — visar idle-skärm med IP-adress
+
+### Schema
+Jobb roterar automatiskt i ordning. Varje jobb har en varaktighet i minuter (decimaler OK, ex. `0.5` = 30 sek).
+
+| Typ | Beskrivning |
+|---|---|
+| `url` | Valfri webbadress |
+| `youtube` | YouTube-liveflöde eller video |
+| `video` | Videofil (mp4, webm, avi, mkv, mov) |
+| `image` | Bildfil (jpg, png, gif, webp, bmp) |
+| `pdf` | PDF eller presentation (ppt, pptx, odp) |
+| `kiosk_chat` | Inbyggd livechat i helskärm |
+
+Videolängd detekteras automatiskt vid filval.
+
+### Löptext (ticker)
+Rullande textrad som overlay. Aktiveras/stängs av utan omstart.
+
+- **Källa** — manuell text eller RSS-flöde (uppdateras automatiskt)
+- **Scrollläge** — en rad i taget eller löpande (kontinuerligt)
+- **Position** — nederkant eller överkant
+- **Hastighet**, **textfärg**, **storlek**, **typsnitt**, **bakgrundsfärg**, **opacitet**
+- **Separator** mellan rader i löpande läge
+
+**BBCode:**
+
+| Tagg | Effekt |
+|---|---|
+| `[b]...[/b]` | Fet |
+| `[i]...[/i]` | Kursiv |
+| `[u]...[/u]` | Understruken |
+| `[s]...[/s]` | Genomstruken |
+| `[color=#ff0000]...[/color]` | Textfärg |
+| `[size=64]...[/size]` | Storlek i px |
+| `[shadow]...[/shadow]` | Skugga |
+| `[glow=#00ff00]...[/glow]` | Glödeffekt |
+
+### Livechat
+- **Kiosk Livechat** — inbyggd chat, besökare ansluter via QR-kod på mobilen. Kan visas som helskärm (schema-typ `kiosk_chat`) eller som overlay ovanpå annat innehåll.
+- **YouTube Livechat** — overlay som visas automatiskt vid YouTube-liveflöde.
+
+Inställningar per chat: position (höger/vänster), bredd, höjd, bakgrundsfärg, opacitet.
+
+### Klocka
+Klock-overlay ovanpå allt innehåll.
+
+- **Stil** — digital (HH:MM:SS) eller analog (canvas med sekundvisare)
+- **Datum** — valfritt datum under klockan (sv: Mån 7 apr)
+- **Position** — övre vänster/höger, nedre vänster/höger, centrerad
+- **Storlek**, **färg**, **opacitet**
+- **Schema** — visa alltid, eller bara under schemalagda aktiviteter
+- Positioneras automatiskt bort från ticker-listen
+
+### Sovläge
+Täcker displayen med svart skärm under angiven tid (ex. 22:00–07:00).
+
+### Wi-Fi
+Hantera trådlöst nätverk direkt från adminpanelen.
+
+- **Status** — aktuell SSID, IP-adress, signalstyrka
+- **Skanna** — listar tillgängliga nätverk med signal och säkerhetstyp
+- **Anslut** — lösenordsmodal (öppna nätverk kräver inget lösenord)
+- **Sparade nätverk** — lista och ta bort
+
+> **Krav:** `wpa_cli` utan lösenord via sudoers. Sätts upp automatiskt av `install.sh`.
+
+### Inställningar
+- **Volym** — systemvolym via PipeWire (`wpctl`)
+- **Ljudprofil** — välj PipeWire-kortprofil (t.ex. HDMI Stereo, Analog Stereo)
+- **Intern display (eDP-1)** — aktivera/inaktivera den inbyggda skärmen med adminpanel
+- **HDMI-utgång** — välj vilken utgång som används (`HDMI-A-1` etc.)
+- **Upplösning** — välj bland tillgängliga lägen eller skriv in manuellt
+
+Om intern display är inaktiverad men **ingen Wi-Fi-anslutning** finns vid uppstart — aktiveras den automatiskt så att adminpanelen alltid är tillgänglig.
+
+### System
+Starta om programmet, starta om datorn, stäng av datorn.
+
+---
+
+## Uppstartflöde
+
+```
+Dator startar
+  └─ Getty autologin → tty1 → .bash_profile
+       └─ kiosk-launcher.sh
+            ├─ Väntar på HDMI-A-1
+            ├─ Startar kiosk/start.sh (Flask) i bakgrunden
+            ├─ Väntar på Flask (127.0.0.1:5000)
+            ├─ Väntar på Wi-Fi / IP-adress (max 40 s)
+            ├─ Genererar /tmp/kiosk-sway-outputs.conf
+            └─ Startar sway
+                 └─ sway-apps.sh
+                      ├─ Väntar på Wi-Fi / IP (max 40 s)
+                      ├─ Pre-seedar Chromium-prefs (translate av)
+                      ├─ Workspace 1 (HDMI): kiosk-display → http://<IP>:5000/display
+                      └─ Workspace 2 (eDP-1): kiosk-admin → http://<IP>:5000/admin
+```
+
+---
+
+## Filstruktur
+
+```
+display/
+├── install.sh                  # Installationsscript
+├── start.sh                    # Gammal cage-baserad start (ej använd)
+└── kiosk/
+    ├── start.sh                # Startar Flask (sätter env-vars, exec python3 app.py)
+    ├── kiosk-launcher.sh       # Startar Flask + sway, genererar output-config
+    ├── sway.conf               # Sway-konfiguration (hide_cursor, fullscreen etc.)
+    ├── sway-apps.sh            # Startar Chromium på rätt workspace
+    ├── hdmi-monitor.sh         # Övervakar HDMI-anslutning
+    ├── hdmi-wait.sh            # Väntar på HDMI vid start
+    ├── kiosk.conf              # Genereras av admin (ljud/skärm-inställningar)
+    ├── kiosk.db                # SQLite-databas
+    ├── app.py                  # Flask-app, API-endpoints, WebSocket
+    ├── db.py                   # Databashjälpfunktioner
+    ├── scheduler.py            # Schemarotation (APScheduler)
+    ├── uploads/                # Uppladdade mediefiler
+    ├── static/
+    │   ├── admin.js
+    │   ├── display.js
+    │   ├── style.css
+    │   └── socket.io.min.js    # Laddas ner av install.sh
+    └── templates/
+        ├── admin.html
+        ├── display.html
+        ├── login.html
+        ├── chat_display.html
+        └── chat_mobile.html
+```
+
+---
+
+## Konfiguration
+
+### kiosk.conf
+Genereras automatiskt av adminpanelen. Kan redigeras manuellt:
+
+```bash
+KIOSK_DISPLAY_MODE=1920x1080@60Hz
+KIOSK_DISPLAY_OUTPUT=HDMI-A-1
+KIOSK_INTERNAL_DISPLAY=off
+KIOSK_AUDIO_PROFILE_INDEX=1
+```
+
+### Miljövariabler (kiosk/start.sh)
+```bash
+export KIOSK_USER=admin
+export KIOSK_PASS=mittlösenord
+export SECRET_KEY=en-lång-hemlig-sträng
+```
+
+---
+
+## Teknikstack
+
+| Komponent | Teknologi |
+|---|---|
+| Backend | Python 3, Flask, Flask-SocketIO, eventlet |
+| Schemaläggare | APScheduler |
+| Databas | SQLite |
+| Frontend | Vanilla JS, Socket.IO |
+| Compositor | Sway (Wayland) |
+| Webbläsare | Chromium (kiosk-läge, ozone-wayland) |
+| Ljud | PipeWire (`wpctl`, `pw-cli`) |
+| Wi-Fi | wpa_supplicant + wpa_cli |
+
+---
+
+## Felsökning
+
+**Kiosken startar inte:**
+```bash
+bash /home/mrfox/codes/display/kiosk/kiosk-launcher.sh
+```
+
+**Flask svarar inte:**
+```bash
+cd /home/mrfox/codes/display/kiosk && bash start.sh
+```
+
+**Sway startar inte / svart skärm:**
+```bash
+# Kontrollera att HDMI är anslutet, sedan:
+SWAYSOCK=$(ls /run/user/$(id -u)/sway-ipc.*.sock 2>/dev/null | head -1)
+swaymsg -s "$SWAYSOCK" exit
+```
+
+**Wi-Fi-sektionen i admin visar "sudo saknas":**
+```bash
+echo 'mrfox ALL=(ALL) NOPASSWD: /usr/sbin/wpa_cli' | sudo tee /etc/sudoers.d/kiosk-wifi
+sudo chmod 440 /etc/sudoers.d/kiosk-wifi
+```
+
+**Markör syns på displayen:**
+Kontrollera att `seat seat0 hide_cursor 3000` finns i `sway.conf` (läggs dit av install.sh).
+
+**Translate-popup i Chromium:**
+`sway-apps.sh` pre-seedar `Default/Preferences` med `"translate": {"enabled": false}` innan Chromium startar. Om det ändå visas: ta bort `/tmp/kiosk-display` och starta om.
diff --git a/kiosk/START.md b/kiosk/START.md
new file mode 100644
index 0000000..b94c8b9
--- /dev/null
+++ b/kiosk/START.md
@@ -0,0 +1,28 @@
+Startup script
+----------------
+
+This repository includes `start.sh` which will, if present, read `kiosk.conf` from the project root and attempt to apply the `KIOSK_DISPLAY_MODE` (e.g. `1920x1080`) using available system tools (`xrandr`, `swaymsg`, `wlr-randr`) before launching the app.
+
+Quick usage:
+
+1. Make the script executable:
+
+```bash
+chmod +x start.sh
+```
+
+2. Set `KIOSK_DISPLAY_MODE` in `kiosk.conf` (example):
+
+```
+KIOSK_DISPLAY_MODE=1920x1080
+```
+
+3. Start the kiosk:
+
+```bash
+./start.sh
+```
+
+Notes:
+- The display mode application is best-effort and depends on tools and compositor support on the target machine.
+- If immediate mode application fails, consider applying the mode in the system startup or using compositor-specific configuration.
diff --git a/kiosk/app.py b/kiosk/app.py
new file mode 100644
index 0000000..5499f33
--- /dev/null
+++ b/kiosk/app.py
@@ -0,0 +1,1527 @@
+import eventlet
+eventlet.monkey_patch()
+
+import os
+import json as _json
+import uuid as _uuid
+import signal
+import socket as _socket
+import psutil
+import subprocess
+import re
+import shutil
+from pathlib import Path
+from werkzeug.utils import secure_filename
+from werkzeug.security import generate_password_hash, check_password_hash
+from flask import Flask, render_template, jsonify, request, abort, session, redirect, url_for, send_file
+from flask_socketio import SocketIO, emit, Namespace
+import db
+import scheduler as sched
+
+# --- Chat user store ---
+CHAT_USERS_FILE = Path(__file__).parent / 'chat_users.json'
+
+def _load_chat_users():
+    try:
+        if CHAT_USERS_FILE.exists():
+            return _json.loads(CHAT_USERS_FILE.read_text())
+    except Exception:
+        pass
+    return {}
+
+def _save_chat_users(users):
+    CHAT_USERS_FILE.write_text(_json.dumps(users, indent=2))
+
+_chat_tokens = {}  # token -> {username, emoji, color}
+
+_CHAT_COLORS = [
+    '#f87171','#fb923c','#fbbf24','#a3e635',
+    '#34d399','#38bdf8','#818cf8','#e879f9',
+    '#f472b6','#94a3b8',
+]
+
+CHAT_AVATARS_DIR    = Path(__file__).parent / 'static' / 'chat_avatars'
+CHAT_AVATARS_DIR.mkdir(exist_ok=True)
+_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.webp']
+
+def _get_avatar_url(username):
+    safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
+    for ext in _AVATAR_EXTS:
+        if (CHAT_AVATARS_DIR / (safe + ext)).exists():
+            return f'/api/chat/avatar/{username}'
+    return None
+
+
+# Load kiosk.conf (if present) into DB state so settings survive app restarts
+def _load_kiosk_conf():
+    conf = Path(os.path.dirname(__file__)) / 'kiosk.conf'
+    if not conf.exists():
+        return
+    try:
+        vals = {}
+        for line in conf.read_text().splitlines():
+            line = line.strip()
+            if not line or line.startswith('#') or '=' not in line:
+                continue
+            k, v = line.split('=', 1)
+            vals[k.strip()] = v.strip().strip('"').strip("'")
+        if 'KIOSK_DISPLAY_MODE' in vals:
+            db.set_state('display_mode', vals['KIOSK_DISPLAY_MODE'])
+        if 'KIOSK_DISPLAY_OUTPUT' in vals:
+            db.set_state('display_output', vals['KIOSK_DISPLAY_OUTPUT'])
+        if 'KIOSK_AUDIO_PROFILE_INDEX' in vals:
+            db.set_state('audio_profile_index', vals['KIOSK_AUDIO_PROFILE_INDEX'])
+        if 'KIOSK_INTERNAL_DISPLAY' in vals:
+            db.set_state('internal_display', vals['KIOSK_INTERNAL_DISPLAY'])
+    except Exception:
+        pass
+
+
+# load at import time
+_load_kiosk_conf()
+
+app = Flask(__name__)
+app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24))
+socketio = SocketIO(app, async_mode='eventlet', cors_allowed_origins='*')
+
+KIOSK_USER = os.environ.get('KIOSK_USER', 'admin')
+KIOSK_PASS = os.environ.get('KIOSK_PASS', 'changeme')
+
+ALLOWED_ROOTS = [Path('/home/mrfox'), Path('/media'), Path('/mnt')]
+MEDIA_EXTENSIONS = {
+    '.mp4', '.webm', '.avi', '.mkv', '.mov',
+    '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp',
+    '.html', '.htm',
+    '.pdf', '.ppt', '.pptx', '.odp',
+}
+UPLOAD_DIR = Path('/home/mrfox/codes/display/kiosk/uploads')
+
+
+def login_required(f):
+    from functools import wraps
+    @wraps(f)
+    def decorated(*args, **kwargs):
+        if not session.get('logged_in'):
+            # For API/XHR requests, return 401 JSON instead of redirecting to login
+            is_xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
+            wants_json = request.is_json or 'application/json' in request.headers.get('Accept', '')
+            if is_xhr or wants_json or request.path.startswith('/api/'):
+                return jsonify({'error': 'login_required'}), 401
+            return redirect(url_for('login', next=request.path))
+        return f(*args, **kwargs)
+    return decorated
+
+
+# --- Login ---
+
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+    error = None
+    if request.method == 'POST':
+        if request.form['username'] == KIOSK_USER and request.form['password'] == KIOSK_PASS:
+            session['logged_in'] = True
+            return redirect(request.args.get('next') or url_for('admin'))
+        error = 'Fel användarnamn eller lösenord'
+    return render_template('login.html', error=error)
+
+
+@app.route('/logout')
+def logout():
+    session.clear()
+    return redirect(url_for('login'))
+
+
+# --- Display page (no auth) ---
+
+def _local_ip():
+    try:
+        s = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
+        s.connect(('8.8.8.8', 80))
+        ip = s.getsockname()[0]
+        s.close()
+        return ip
+    except Exception:
+        return '127.0.0.1'
+
+
+@app.route('/display')
+def display():
+    return render_template('display.html', local_ip=_local_ip())
+
+
+# --- Admin page ---
+
+@app.route('/admin')
+@login_required
+def admin():
+    return render_template('admin.html')
+
+
+# --- API: sysinfo ---
+
+@app.route('/api/sysinfo')
+@login_required
+def sysinfo():
+    uptime = subprocess.check_output('uptime -p', shell=True).decode().strip()
+    state = db.get_state()
+    return jsonify({
+        'cpu':           psutil.cpu_percent(interval=1),
+        'ram':           psutil.virtual_memory().percent,
+        'disk':          psutil.disk_usage('/').percent,
+        'uptime':        uptime,
+        'active_type':   state.get('current_type', '—'),
+        'active_source': state.get('current_source', '—'),
+    })
+
+
+# --- API: jobs ---
+
+@app.route('/api/jobs', methods=['GET'])
+@login_required
+def get_jobs():
+    return jsonify(db.get_all_jobs())
+
+
+@app.route('/api/jobs', methods=['POST'])
+@login_required
+def add_job():
+    data = request.get_json()
+    if not data:
+        abort(400)
+    required = ('label', 'duration', 'type', 'source')
+    if not all(k in data for k in required):
+        abort(400)
+    job_id = db.add_job(data['label'], float(data['duration']), data['type'], data['source'])
+    sched.reload_jobs()
+    return jsonify({'id': job_id}), 201
+
+
+@app.route('/api/jobs/<int:job_id>', methods=['DELETE'])
+@login_required
+def delete_job(job_id):
+    db.delete_job(job_id)
+    sched.reload_jobs()
+    return '', 204
+
+
+@app.route('/api/jobs/<int:job_id>', methods=['PATCH'])
+@login_required
+def toggle_job(job_id):
+    # Allow partial update (edit fields) if JSON body provided, otherwise toggle enabled
+    data = request.get_json(silent=True)
+    if data:
+        allowed = {}
+        for k in ('label', 'duration', 'type', 'source', 'enabled'):
+            if k in data:
+                allowed[k] = data[k]
+        if 'duration' in allowed:
+            allowed['duration'] = float(allowed['duration'])
+        db.update_job(job_id, **allowed)
+        sched.reload_jobs()
+        return '', 204
+    else:
+        result = db.toggle_job(job_id)
+        if result is None:
+            abort(404)
+        sched.reload_jobs()
+        return jsonify(result)
+
+
+@app.route('/api/jobs/reorder', methods=['POST'])
+@login_required
+def reorder_jobs():
+    data = request.get_json()
+    if not data or 'order' not in data or not isinstance(data['order'], list):
+        abort(400)
+    ids = [int(x) for x in data['order']]
+    db.reorder_jobs(ids)
+    sched.reload_jobs()
+    return '', 204
+
+
+# --- Kiosk Livechat routes ---
+
+@app.route('/chat-display')
+def chat_display():
+    return render_template('chat_display.html', local_ip=_local_ip())
+
+
+@app.route('/chat-mobile')
+def chat_mobile():
+    return render_template('chat_mobile.html')
+
+
+@app.route('/api/chat-qr')
+def chat_qr():
+    try:
+        import qrcode as _qr
+        import io as _io
+        url = f'http://{_local_ip()}:5000/chat-mobile'
+        qr = _qr.QRCode(box_size=20, border=2)
+        qr.add_data(url)
+        qr.make(fit=True)
+        img = qr.make_image()
+        buf = _io.BytesIO()
+        img.save(buf, format='PNG')
+        buf.seek(0)
+        return send_file(buf, mimetype='image/png', max_age=30)
+    except ImportError:
+        abort(501)
+
+
+@app.route('/api/chat/register', methods=['POST'])
+def chat_register():
+    data     = request.get_json(silent=True) or {}
+    username = str(data.get('username', '')).strip()[:32]
+    password = str(data.get('password', '')).strip()
+    emoji    = str(data.get('emoji', '😀')).strip()[:4]
+    if not username or not password:
+        return jsonify({'error': 'Användarnamn och lösenord krävs'}), 400
+    users = _load_chat_users()
+    if username.lower() in {k.lower() for k in users}:
+        return jsonify({'error': 'Användarnamnet är redan taget'}), 409
+    color = _CHAT_COLORS[len(users) % len(_CHAT_COLORS)]
+    users[username] = {
+        'password_hash': generate_password_hash(password),
+        'emoji': emoji,
+        'color': color,
+    }
+    _save_chat_users(users)
+    token = str(_uuid.uuid4())
+    _chat_tokens[token] = {'username': username, 'emoji': emoji, 'color': color, 'avatar_url': None}
+    return jsonify({'token': token, 'username': username, 'emoji': emoji, 'color': color, 'avatar_url': None})
+
+
+@app.route('/api/chat/login', methods=['POST'])
+def chat_login():
+    data     = request.get_json(silent=True) or {}
+    username = str(data.get('username', '')).strip()
+    password = str(data.get('password', '')).strip()
+    if not username or not password:
+        return jsonify({'error': 'Fyll i användarnamn och lösenord'}), 400
+    users = _load_chat_users()
+    key   = next((k for k in users if k.lower() == username.lower()), None)
+    if not key or not check_password_hash(users[key]['password_hash'], password):
+        return jsonify({'error': 'Fel användarnamn eller lösenord'}), 401
+    u          = users[key]
+    avatar_url = _get_avatar_url(key)
+    token = str(_uuid.uuid4())
+    _chat_tokens[token] = {'username': key, 'emoji': u['emoji'], 'color': u['color'], 'avatar_url': avatar_url}
+    return jsonify({'token': token, 'username': key, 'emoji': u['emoji'], 'color': u['color'], 'avatar_url': avatar_url})
+
+
+@app.route('/api/chat/validate', methods=['POST'])
+def chat_validate():
+    data  = request.get_json(silent=True) or {}
+    token = str(data.get('token', ''))
+    user  = _chat_tokens.get(token)
+    if not user:
+        return jsonify({'error': 'Ogiltig token'}), 401
+    return jsonify({'username': user['username'], 'emoji': user['emoji'], 'color': user['color'], 'avatar_url': user.get('avatar_url')})
+
+
+@app.route('/api/chat/avatar/<username>')
+def serve_chat_avatar(username):
+    safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
+    for ext in _AVATAR_EXTS:
+        p = CHAT_AVATARS_DIR / (safe + ext)
+        if p.exists():
+            return send_file(p)
+    abort(404)
+
+
+@app.route('/api/chat/avatar', methods=['POST'])
+def upload_chat_avatar():
+    token     = request.form.get('token', '')
+    user_info = _chat_tokens.get(token)
+    if not user_info:
+        return jsonify({'error': 'Ogiltig token'}), 401
+    f = request.files.get('avatar')
+    if not f or not f.filename:
+        return jsonify({'error': 'Ingen fil'}), 400
+    ext = Path(secure_filename(f.filename)).suffix.lower()
+    if ext not in _AVATAR_EXTS:
+        return jsonify({'error': 'Använd jpg, png eller webp'}), 400
+    username = user_info['username']
+    safe     = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
+    for old_ext in _AVATAR_EXTS:
+        old = CHAT_AVATARS_DIR / (safe + old_ext)
+        if old.exists():
+            old.unlink()
+    dest = CHAT_AVATARS_DIR / (safe + ext)
+    f.save(str(dest))
+    avatar_url = f'/api/chat/avatar/{username}'
+    user_info['avatar_url'] = avatar_url
+    users = _load_chat_users()
+    if username in users:
+        users[username]['avatar_url'] = avatar_url
+        _save_chat_users(users)
+    return jsonify({'avatar_url': avatar_url})
+
+
+# --- Kiosk Livechat SocketIO namespace ---
+
+class ChatNamespace(Namespace):
+    _users    = {}   # sid -> {name, color, emoji}
+    _messages = []   # recent message history (last 50)
+
+    def on_connect(self):
+        sid = request.sid
+        if self._messages:
+            socketio.emit('chat_history', self._messages, to=sid, namespace='/chat')
+        socketio.emit('viewer_count', {'count': len(self._users)}, to=sid, namespace='/chat')
+
+    def on_join(self, data):
+        token = str(data.get('token', '')).strip()
+        if not token:
+            return
+        user_info = _chat_tokens.get(token)
+        if not user_info:
+            self.emit('auth_error', {'error': 'Ogiltig token, logga in igen'})
+            return
+        if request.sid in self._users:
+            return
+        username   = user_info['username']
+        emoji      = user_info['emoji']
+        color      = user_info['color']
+        avatar_url = user_info.get('avatar_url')
+        self._users[request.sid] = {'name': username, 'color': color, 'emoji': emoji, 'avatar_url': avatar_url}
+        # Re-send history to authenticated client so chat-mobile gets it after login
+        if self._messages:
+            socketio.emit('chat_history', self._messages, to=request.sid, namespace='/chat')
+        socketio.emit('system_message', {'text': f'{username} gick med'}, namespace='/chat')
+        socketio.emit('viewer_count',   {'count': len(self._users)},       namespace='/chat')
+
+    def on_chat_message(self, data):
+        user = self._users.get(request.sid)
+        if not user:
+            return
+        text = str(data.get('text', '')).strip()[:300]
+        if not text:
+            return
+        msg = {
+            'name':       user['name'],
+            'color':      user['color'],
+            'emoji':      user.get('emoji'),
+            'avatar_url': user.get('avatar_url'),
+            'text':       text,
+        }
+        self._messages.append(msg)
+        if len(self._messages) > 50:
+            self._messages.pop(0)
+        socketio.emit('chat_message', msg, namespace='/chat')
+
+    def on_disconnect(self):
+        user = self._users.pop(request.sid, None)
+        if user:
+            socketio.emit('system_message', {'text': f"{user['name']} lämnade"}, namespace='/chat')
+            socketio.emit('viewer_count',   {'count': len(self._users)},           namespace='/chat')
+
+
+socketio.on_namespace(ChatNamespace('/chat'))
+
+
+# --- WebSocket ---
+
+def _is_sleep_active(start, end):
+    try:
+        now = __import__('datetime').datetime.now()
+        cur = now.hour * 60 + now.minute
+        sh, sm = map(int, start.split(':'))
+        eh, em = map(int, end.split(':'))
+        s, e = sh * 60 + sm, eh * 60 + em
+        return (cur >= s or cur < e) if s > e else (s <= cur < e)
+    except Exception:
+        return False
+
+
+@socketio.on('connect')
+def on_connect():
+    state = db.get_state()
+    if state.get('current_type'):
+        emit('switch', {
+            'type':      state['current_type'],
+            'source':    state.get('current_source', ''),
+            'scheduled': False,
+        })
+    if state.get('sleep_enabled') == '1':
+        active = _is_sleep_active(
+            state.get('sleep_start', '22:00'),
+            state.get('sleep_end',   '07:00'),
+        )
+        emit('sleep_toggle', {'active': active})
+    emit('clock_settings', {
+        'enabled':       state.get('clock_enabled',       '0') == '1',
+        'style':         state.get('clock_style',         'digital'),
+        'show_date':     state.get('clock_show_date',     '0') == '1',
+        'position':      state.get('clock_position',      'top-right'),
+        'size':          float(state.get('clock_size',    '2')),
+        'color':         state.get('clock_color',         '#ffffff'),
+        'opacity':       int(state.get('clock_opacity',   '90')),
+        'schedule_mode': state.get('clock_schedule_mode', 'always'),
+    })
+
+
+@socketio.on('admin_switch')
+def handle_switch(data):
+    type_ = data.get('type')
+    source = data.get('source', '')
+    db.set_current(type_, source)
+    socketio.emit('switch', {'type': type_, 'source': source, 'scheduled': False})
+
+
+# --- File browser ---
+
+@app.route('/files')
+def serve_file():
+    path = request.args.get('path', '')
+    if not path:
+        abort(400)
+    p = Path(path).resolve()
+    if not any(p.is_relative_to(r) for r in ALLOWED_ROOTS):
+        abort(403)
+    if not p.is_file():
+        abort(404)
+    return send_file(p)
+
+
+@app.route('/api/upload', methods=['POST'])
+@login_required
+def upload():
+    if 'file' not in request.files:
+        abort(400)
+    f = request.files['file']
+    if not f.filename:
+        abort(400)
+    name = secure_filename(f.filename)
+    ext = Path(name).suffix.lower()
+    if ext not in MEDIA_EXTENSIONS:
+        abort(415)
+    UPLOAD_DIR.mkdir(exist_ok=True)
+    dest = UPLOAD_DIR / name
+    if dest.exists():
+        stem = Path(name).stem
+        i = 1
+        while dest.exists():
+            dest = UPLOAD_DIR / f'{stem}_{i}{ext}'
+            i += 1
+    f.save(dest)
+
+    # Konvertera presentationer till PDF med LibreOffice om tillgängligt
+    if ext in {'.ppt', '.pptx', '.odp'} and shutil.which('libreoffice'):
+        try:
+            subprocess.run(
+                ['libreoffice', '--headless', '--convert-to', 'pdf', str(dest), '--outdir', str(UPLOAD_DIR)],
+                timeout=120, check=True,
+                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+            )
+            pdf_path = UPLOAD_DIR / (dest.stem + '.pdf')
+            if pdf_path.exists():
+                dest.unlink()
+                dest = pdf_path
+        except Exception:
+            pass
+
+    file_url = '/files?path=' + str(dest)
+    return jsonify({'path': str(dest), 'url': file_url})
+
+
+@app.route('/api/browse')
+@login_required
+def browse():
+    req_path = request.args.get('path', str(Path('/home/mrfox')))
+    p = Path(req_path).resolve()
+    if not any(p.is_relative_to(r) for r in ALLOWED_ROOTS):
+        abort(403)
+    if not p.is_dir():
+        abort(404)
+    entries = []
+    try:
+        for entry in sorted(p.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
+            if entry.name.startswith('.'):
+                continue
+            if entry.is_dir():
+                entries.append({'name': entry.name, 'path': str(entry), 'type': 'dir'})
+            elif entry.suffix.lower() in MEDIA_EXTENSIONS:
+                entries.append({'name': entry.name, 'path': str(entry), 'type': 'file', 'ext': entry.suffix.lower()})
+    except PermissionError:
+        pass
+    parent = str(p.parent)
+    if not any(Path(parent).is_relative_to(r) for r in ALLOWED_ROOTS):
+        parent = None
+    return jsonify({'path': str(p), 'parent': parent, 'entries': entries})
+
+
+# --- Settings ---
+
+def _audio_device_id():
+    """Return the PipeWire interface-id of the first Audio/Device."""
+    import json as _json
+    try:
+        out = subprocess.check_output(
+            ['pw-dump'], timeout=5,
+            env={**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
+        ).decode()
+        for node in _json.loads(out):
+            if node.get('type') != 'PipeWire:Interface:Device':
+                continue
+            if node.get('info', {}).get('props', {}).get('media.class') == 'Audio/Device':
+                return node.get('id')
+    except Exception:
+        pass
+    return None
+
+
+def _parse_profiles(text):
+    """Parse pw-cli enum-params EnumProfile output into a list of dicts."""
+    profiles, cur, last_key = [], {}, None
+    for line in text.splitlines():
+        s = line.strip()
+        if s.startswith('Object:') and 'Profile' in s:
+            if 'index' in cur:
+                profiles.append(cur)
+            cur, last_key = {}, None
+        elif 'Profile:index'       in s: last_key = 'index'
+        elif 'Profile:description' in s: last_key = 'description'
+        elif 'Profile:name'        in s and 'description' not in s: last_key = 'name'
+        elif 'Profile:available'   in s: last_key = 'available'
+        elif last_key == 'index' and s.startswith('Int '):
+            cur['index'] = int(s.split()[1]); last_key = None
+        elif last_key in ('name', 'description') and s.startswith('String '):
+            m = re.match(r'String "(.+)"', s)
+            if m: cur[last_key] = m.group(1)
+            last_key = None
+        elif last_key == 'available' and 'Availability' in s:
+            cur['available'] = 'yes' in s; last_key = None
+    if 'index' in cur:
+        profiles.append(cur)
+    skip = {'off', 'pro-audio'}
+    # Include all non-trivial profiles regardless of availability so user can always switch
+    return [p for p in profiles
+            if p.get('name') not in skip
+            and not p.get('name', '').startswith('input:')]
+
+
+def _apply_saved_audio():
+    """Apply the saved audio profile from DB state. Best-effort, non-fatal."""
+    try:
+        state     = db.get_state()
+        idx       = int(state.get('audio_profile_index', 1))
+        dev_id    = _audio_device_id()
+        pw_env    = {**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
+        if dev_id is not None:
+            subprocess.run(
+                ['pw-cli', 's', str(dev_id), 'Profile', f'{{ index: {idx} }}'],
+                timeout=5, env=pw_env
+            )
+    except Exception:
+        pass
+
+
+def _apply_display_mode(mode_str, target_output=None):
+    """Best-effort apply display resolution given a string like '1920x1080'.
+    Tries xrandr, swaymsg, or wlr-randr when available. Non-fatal on failure."""
+    m = re.search(r"(\d+)x(\d+)", mode_str)
+    if not m:
+        return False
+    width, height = m.group(1), m.group(2)
+
+    # find first connected output from /sys/class/drm unless a target_output is given
+    out_name = None
+    if target_output:
+        out_name = target_output
+    else:
+        try:
+            for conn in sorted(Path('/sys/class/drm').iterdir()):
+                status_file = conn / 'status'
+                if not status_file.exists():
+                    continue
+                if status_file.read_text().strip() == 'connected':
+                    parts = conn.name.split('-', 1)
+                    if len(parts) == 2:
+                        out_name = parts[1]
+                        break
+        except Exception:
+            out_name = None
+
+    # Auto-detect WAYLAND_DISPLAY if not set (e.g. when running as a systemd service)
+    env = os.environ.copy()
+    if not env.get('WAYLAND_DISPLAY'):
+        xdg_runtime = env.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
+        try:
+            for sock in sorted(Path(xdg_runtime).glob('wayland-[0-9]*')):
+                if sock.suffix != '.lock':
+                    env['WAYLAND_DISPLAY'] = sock.name
+                    break
+        except Exception:
+            pass
+
+    # Prefer Wayland wlroots tool first (wlr-randr)
+    if shutil.which('wlr-randr') and out_name:
+        try:
+            # if mode_str contains refresh token like '1920x1080@60.000000', strip @Hz for wlr-randr
+            token = mode_str.split('@', 1)[0]
+            subprocess.run(['wlr-randr', '--output', out_name, '--mode', token], timeout=5, check=True, env=env)
+            return True
+        except Exception:
+            pass
+
+    # Try swaymsg next (Wayland sway)
+    if shutil.which('swaymsg') and out_name:
+        try:
+            # swaymsg expects: output <name> mode <width> <height>
+            subprocess.run(['swaymsg', 'output', out_name, 'mode', width, height], timeout=5, check=True, env=env)
+            return True
+        except Exception:
+            pass
+
+    # Finally try xrandr (X11). Skip xrandr when running in Wayland session.
+    if os.environ.get('XDG_SESSION_TYPE', '').lower() == 'wayland' or os.environ.get('WAYLAND_DISPLAY'):
+        # running under Wayland — avoid xrandr
+        return False
+    if shutil.which('xrandr') and out_name:
+        # try common DISPLAY fallbacks so this works when daemonized
+        for disp in (None, ':0', ':0.0', ':1'):
+            try:
+                env = os.environ.copy()
+                if disp:
+                    env['DISPLAY'] = disp
+                subprocess.run(['xrandr', '--output', out_name, '--mode', f'{width}x{height}'], env=env, timeout=5, check=True)
+                return True
+            except Exception:
+                continue
+
+    return False
+
+
+@app.route('/api/settings/apply_display', methods=['POST'])
+@login_required
+def apply_display_now():
+    # Apply display_mode from posted JSON or from saved state
+    data = request.get_json(silent=True) or {}
+    mode = data.get('display_mode')
+    target = data.get('display_output') or None
+    if not mode:
+        state = db.get_state()
+        mode = state.get('display_mode', '')
+    if not mode:
+        return jsonify({'ok': False, 'error': 'no_mode'}), 400
+    try:
+        ok = _apply_display_mode(str(mode), target_output=target)
+        # Re-apply saved audio profile — display changes can reset PipeWire routing
+        _apply_saved_audio()
+        return jsonify({'ok': bool(ok)})
+    except Exception as e:
+        return jsonify({'ok': False, 'error': str(e)}), 500
+
+
+@app.route('/api/audio/profiles')
+@login_required
+def audio_profiles():
+    dev_id = _audio_device_id()
+    if dev_id is None:
+        return jsonify([])
+    try:
+        pw_env = {**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
+        out = subprocess.check_output(
+            ['pw-cli', 'enum-params', str(dev_id), 'EnumProfile'],
+            timeout=5, env=pw_env
+        ).decode()
+        return jsonify(_parse_profiles(out))
+    except Exception:
+        return jsonify([])
+
+
+@app.route('/api/displays')
+@login_required
+def list_displays():
+    outputs = []
+    try:
+        for conn in sorted(Path('/sys/class/drm').iterdir()):
+            status_file = conn / 'status'
+            if not status_file.exists():
+                continue
+            parts = conn.name.split('-', 1)
+            if len(parts) != 2:
+                continue
+            connector = parts[1]
+            connected = status_file.read_text().strip() == 'connected'
+            outputs.append({'name': connector, 'connected': connected})
+    except Exception:
+        pass
+
+    # Best-effort: if xrandr is available, append available modes per output
+    try:
+        if shutil.which('xrandr'):
+            out = ''
+            # try without DISPLAY and common DISPLAY fallbacks
+            for disp in (None, ':0', ':0.0', ':1'):
+                try:
+                    env = os.environ.copy()
+                    if disp:
+                        env['DISPLAY'] = disp
+                    out = subprocess.check_output(['xrandr', '--query'], env=env, timeout=5).decode(errors='ignore')
+                    if out and ' connected' in out:
+                        break
+                except Exception:
+                    out = ''
+            lines = out.splitlines() if out else []
+            cur_out = None
+            modes_map = {}
+            for line in lines:
+                m = re.match(r"^([A-Za-z0-9-]+) connected", line)
+                if m:
+                    cur_out = m.group(1)
+                    modes_map[cur_out] = []
+                    modes_current = None
+                    continue
+                if cur_out:
+                    m2 = re.match(r"^\s+(\d+)x(\d+)\s+([0-9\.]+)\s*Hz?(.*)$", line)
+                    if m2:
+                        w = m2.group(1)
+                        h = m2.group(2)
+                        hz = m2.group(3)
+                        token = f"{w}x{h}@{hz}"
+                        if token not in modes_map[cur_out]:
+                            modes_map[cur_out].append(token)
+                        rest = m2.group(4) or ''
+                        if '*' in rest or 'current' in rest:
+                            modes_map.setdefault('__current__', {})
+                            modes_map['__current__'][cur_out] = token
+                    elif line.strip() == '':
+                        cur_out = None
+            if modes_map:
+                for o in outputs:
+                    if o['name'] in modes_map and modes_map[o['name']]:
+                        o['modes'] = modes_map[o['name']]
+                # extract current modes if any
+                curmap = modes_map.get('__current__', {})
+                if curmap:
+                    for o in outputs:
+                        if o['name'] in curmap:
+                            o['current_mode'] = curmap[o['name']]
+    except Exception:
+        pass
+
+    # If no modes found yet and wlr-randr is available (Wayland wlroots), try parsing it
+    try:
+        if not any(o.get('modes') for o in outputs) and shutil.which('wlr-randr'):
+            env = os.environ.copy()
+            if not env.get('WAYLAND_DISPLAY'):
+                xdg_runtime = env.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
+                try:
+                    for sock in sorted(Path(xdg_runtime).glob('wayland-[0-9]*')):
+                        if sock.suffix != '.lock':
+                            env['WAYLAND_DISPLAY'] = sock.name
+                            break
+                except Exception:
+                    pass
+            out = subprocess.check_output(['wlr-randr'], timeout=5, env=env).decode(errors='ignore')
+            lines = out.splitlines()
+            cur_out = None
+            modes_map = {}
+            for line in lines:
+                if line.strip() == '':
+                    continue
+                # output lines usually start without indent
+                if line[0] != ' ' and line[0] != '\t':
+                    cur_out = line.strip().split()[0]
+                    modes_map[cur_out] = []
+                    continue
+                # mode lines are indented and often contain WxH and optional (current)
+                m = re.search(r"(\d+)x(\d+)\s+px,\s+([0-9\.]+)\s+Hz(?:.*(current))?", line)
+                if cur_out and m:
+                    w = m.group(1)
+                    h = m.group(2)
+                    hz = m.group(3)
+                    token = f"{w}x{h}@{hz}"
+                    if token not in modes_map[cur_out]:
+                        modes_map[cur_out].append(token)
+                    if 'current' in line:
+                        modes_map.setdefault('__current__', {})
+                        modes_map['__current__'][cur_out] = token
+            current_map = modes_map.pop('__current__', {})
+            if modes_map:
+                for o in outputs:
+                    if o['name'] in modes_map and modes_map[o['name']]:
+                        o['modes'] = modes_map[o['name']]
+                    if o['name'] in current_map:
+                        o['current_mode'] = current_map[o['name']]
+    except Exception:
+        pass
+
+    return jsonify(outputs)
+
+
+@app.route('/api/volume', methods=['GET'])
+@login_required
+def get_volume():
+    try:
+        out = subprocess.check_output(['wpctl', 'get-volume', '@DEFAULT_AUDIO_SINK@'], timeout=5).decode()
+        # Format: "Volume: 1.00"
+        vol = round(float(out.split()[1]) * 100)
+        return jsonify({'volume': vol})
+    except Exception:
+        return jsonify({'volume': 100})
+
+
+@app.route('/api/volume', methods=['POST'])
+@login_required
+def set_volume():
+    data = request.get_json()
+    vol = max(0, min(100, int(data.get('volume', 100))))
+    subprocess.run(['wpctl', 'set-volume', '@DEFAULT_AUDIO_SINK@', f'{vol/100:.2f}'], timeout=5)
+    return '', 204
+
+
+@app.route('/api/settings', methods=['GET'])
+@login_required
+def get_settings():
+    state = db.get_state()
+    return jsonify({
+        'audio_profile_index': int(state.get('audio_profile_index', 1)),
+        'display_output':      state.get('display_output', 'last'),
+        'display_mode':        state.get('display_mode', ''),
+        'internal_display':    state.get('internal_display', 'on'),
+    })
+
+
+@app.route('/api/settings', methods=['POST'])
+@login_required
+def save_settings():
+    data = request.get_json()
+    if not data:
+        abort(400)
+    profile_index    = int(data.get('audio_profile_index', 1))
+    display          = data.get('display_output', 'last')
+    display_mode     = str(data.get('display_mode', '')).strip()
+    internal_display = 'on' if data.get('internal_display', True) else 'off'
+    db.set_state('audio_profile_index', str(profile_index))
+    db.set_state('display_output', display)
+    db.set_state('internal_display', internal_display)
+    if display_mode:
+        db.set_state('display_mode', display_mode)
+    conf = Path(os.path.dirname(__file__)) / 'kiosk.conf'
+    conf.write_text(
+        '# Genererad av kiosk-admin — ändra ej manuellt\n'
+        f'KIOSK_AUDIO_PROFILE_INDEX={profile_index}\n'
+        f'KIOSK_DISPLAY_OUTPUT={display}\n'
+        f'KIOSK_INTERNAL_DISPLAY={internal_display}\n'
+        + (f'KIOSK_DISPLAY_MODE={display_mode}\n' if display_mode else '')
+    )
+    # Tillämpa profilen direkt utan omstart
+    dev_id = _audio_device_id()
+    if dev_id is not None:
+        try:
+            pw_env = {**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
+            subprocess.run(
+                ['pw-cli', 's', str(dev_id), 'Profile', f'{{ index: {profile_index} }}'],
+                timeout=5, check=True, env=pw_env
+            )
+        except Exception:
+            pass
+    return '', 204
+
+
+# --- Söv-läge ---
+
+@app.route('/api/sleep', methods=['GET'])
+@login_required
+def get_sleep():
+    state = db.get_state()
+    return jsonify({
+        'enabled': state.get('sleep_enabled', '0') == '1',
+        'start':   state.get('sleep_start', '22:00'),
+        'end':     state.get('sleep_end',   '07:00'),
+    })
+
+
+@app.route('/api/sleep', methods=['POST'])
+@login_required
+def set_sleep():
+    data = request.get_json()
+    if not data:
+        abort(400)
+    enabled = bool(data.get('enabled', False))
+    start   = str(data.get('start', '22:00'))
+    end     = str(data.get('end',   '07:00'))
+    # Validera HH:MM
+    import re as _re2
+    if not _re2.match(r'^\d{2}:\d{2}$', start): start = '22:00'
+    if not _re2.match(r'^\d{2}:\d{2}$', end):   end   = '07:00'
+    db.set_state('sleep_enabled', '1' if enabled else '0')
+    db.set_state('sleep_start',   start)
+    db.set_state('sleep_end',     end)
+    sched.reload_sleep_jobs()
+    # Skicka omedelbart om aktiv
+    active = enabled and _is_sleep_active(start, end)
+    socketio.emit('sleep_toggle', {'active': active})
+    return '', 204
+
+
+# --- Ticker ---
+
+import re as _re
+import urllib.request as _urlreq
+import xml.etree.ElementTree as _ET
+from datetime import datetime as _dt, timedelta as _td
+
+_HEX_COLOR  = _re.compile(r'^#[0-9a-fA-F]{6}$')
+_rss_cache  = {'url': None, 'items': [], 'fetched_at': None}
+
+
+def _fetch_rss(url, max_items=30):
+    global _rss_cache
+    now = _dt.now()
+    if (_rss_cache['url'] == url and _rss_cache['fetched_at']
+            and now - _rss_cache['fetched_at'] < _td(minutes=2)):
+        return _rss_cache['items']
+    try:
+        req = _urlreq.Request(url, headers={'User-Agent': 'KioskRSS/1.0'})
+        with _urlreq.urlopen(req, timeout=10) as r:
+            raw = r.read()
+        root = _ET.fromstring(raw)
+        items = []
+        # RSS 2.0
+        for item in root.findall('.//item'):
+            t = (item.findtext('title') or '').strip()
+            if t:
+                items.append(t)
+        # Atom
+        if not items:
+            ns = {'a': 'http://www.w3.org/2005/Atom'}
+            for entry in root.findall('.//a:entry', ns):
+                t = (entry.findtext('a:title', namespaces=ns) or '').strip()
+                if t:
+                    items.append(t)
+        _rss_cache = {'url': url, 'items': items[:max_items], 'fetched_at': now}
+    except Exception:
+        pass  # keep stale cache on error
+    return _rss_cache['items']
+
+
+@app.route('/api/ticker/rss')
+def get_ticker_rss():
+    state = db.get_state()
+    url   = state.get('ticker_rss_url', '').strip()
+    if not url:
+        return jsonify({'items': [], 'error': 'Ingen RSS-URL konfigurerad'})
+    items = _fetch_rss(url)
+    return jsonify({'items': items})
+
+
+@app.route('/api/ticker', methods=['GET'])
+def get_ticker():
+    state = db.get_state()
+    return jsonify({
+        'enabled':      state.get('ticker_enabled', '0') == '1',
+        'source':       state.get('ticker_source', 'text'),
+        'text':         state.get('ticker_text', ''),
+        'rss_url':      state.get('ticker_rss_url', ''),
+        'rss_interval': int(state.get('ticker_rss_interval', '10')),
+        'pps':          int(state.get('ticker_pps', '150')),
+        'mode':         state.get('ticker_mode', 'sequential'),
+        'separator':    state.get('ticker_separator', '◆'),
+        'position':     state.get('ticker_position', 'bottom'),
+        'text_color':   state.get('ticker_text_color', '#ffffff'),
+        'font_size':    float(state.get('ticker_font_size', '2.0')),
+        'font_family':  state.get('ticker_font_family', 'sans-serif'),
+        'bar_color':    state.get('ticker_bar_color', '#000000'),
+        'bar_opacity':  int(state.get('ticker_bar_opacity', '72')),
+        'bar_padding':  float(state.get('ticker_bar_padding', '0.45')),
+    })
+
+
+@app.route('/api/ticker', methods=['POST'])
+@login_required
+def set_ticker():
+    data = request.get_json()
+    if not data:
+        abort(400)
+    enabled      = bool(data.get('enabled', False))
+    source       = 'rss' if data.get('source') == 'rss' else 'text'
+    text         = str(data.get('text', ''))
+    rss_url      = str(data.get('rss_url', ''))
+    rss_interval = max(1, min(1440, int(data.get('rss_interval', 10))))
+    pps          = max(30, min(800, int(data.get('pps', 150))))
+    mode         = 'continuous' if data.get('mode') == 'continuous' else 'sequential'
+    separator    = str(data.get('separator', '◆'))[:20]
+    position     = 'top' if data.get('position') == 'top' else 'bottom'
+    text_color   = str(data.get('text_color', '#ffffff'))
+    font_size    = max(0.5, min(6.0, float(data.get('font_size', 2.0))))
+    font_family  = str(data.get('font_family', 'sans-serif'))
+    bar_color    = str(data.get('bar_color', '#000000'))
+    bar_opacity  = max(0, min(100, int(data.get('bar_opacity', 72))))
+    bar_padding  = max(0.1, min(2.0, float(data.get('bar_padding', 0.45))))
+    if not _HEX_COLOR.match(text_color): text_color = '#ffffff'
+    if not _HEX_COLOR.match(bar_color):  bar_color  = '#000000'
+    db.set_state('ticker_enabled',      '1' if enabled else '0')
+    db.set_state('ticker_source',       source)
+    db.set_state('ticker_text',         text)
+    db.set_state('ticker_rss_url',      rss_url)
+    db.set_state('ticker_rss_interval', str(rss_interval))
+    db.set_state('ticker_pps',          str(pps))
+    db.set_state('ticker_mode',         mode)
+    db.set_state('ticker_separator',    separator)
+    db.set_state('ticker_position',     position)
+    db.set_state('ticker_text_color',   text_color)
+    db.set_state('ticker_font_size',    str(font_size))
+    db.set_state('ticker_font_family',  font_family)
+    db.set_state('ticker_bar_color',    bar_color)
+    db.set_state('ticker_bar_opacity',  str(bar_opacity))
+    db.set_state('ticker_bar_padding',  str(bar_padding))
+    # Invalidate RSS cache when URL changes
+    if source == 'rss':
+        _rss_cache['fetched_at'] = None
+    payload = {
+        'enabled': enabled, 'source': source,
+        'text': text, 'rss_url': rss_url, 'rss_interval': rss_interval,
+        'pps': pps, 'mode': mode, 'separator': separator, 'position': position,
+        'text_color': text_color, 'font_size': font_size, 'font_family': font_family,
+        'bar_color': bar_color, 'bar_opacity': bar_opacity, 'bar_padding': bar_padding,
+    }
+    socketio.emit('ticker', payload)
+    return '', 204
+
+
+# --- Chat overlay ---
+
+@app.route('/api/chat', methods=['GET'])
+def get_chat():
+    state = db.get_state()
+    return jsonify({
+        'enabled':          state.get('chat_enabled',          '0') == '1',
+        'position':         state.get('chat_position',         'right'),
+        'width_pct':        int(state.get('chat_width_pct',    '25')),
+        'height':           int(state.get('chat_height',       '100')),
+        'bg_color':         state.get('chat_bg_color',         '#000000'),
+        'bg_opacity':       int(state.get('chat_bg_opacity',   '70')),
+        'text_color':       state.get('chat_text_color',       '#ffffff'),
+        'font_size':        int(state.get('chat_font_size',    '14')),
+        'kiosk_enabled':    state.get('kiosk_chat_enabled',    '0') == '1',
+        'kiosk_position':   state.get('kiosk_chat_position',   'right'),
+        'kiosk_width_pct':  int(state.get('kiosk_chat_width',  '30')),
+        'kiosk_height':     int(state.get('kiosk_chat_height', '80')),
+    })
+
+
+@app.route('/api/chat', methods=['POST'])
+@login_required
+def set_chat():
+    data = request.get_json()
+    if not data:
+        abort(400)
+    enabled    = bool(data.get('enabled', False))
+    position   = 'left' if data.get('position') == 'left' else 'right'
+    width_pct  = max(5,   min(60,  int(data.get('width_pct',  25))))
+    height     = max(30,  min(100, int(data.get('height',    100))))
+    bg_color   = str(data.get('bg_color',   '#000000'))
+    bg_opacity = max(0,   min(100, int(data.get('bg_opacity',  70))))
+    text_color = str(data.get('text_color', '#ffffff'))
+    font_size  = max(8,   min(48,  int(data.get('font_size',   14))))
+    if not _HEX_COLOR.match(bg_color):   bg_color   = '#000000'
+    if not _HEX_COLOR.match(text_color): text_color = '#ffffff'
+    kiosk_enabled   = bool(data.get('kiosk_enabled', False))
+    kiosk_position  = 'left' if data.get('kiosk_position') == 'left' else 'right'
+    kiosk_width_pct = max(10, min(60, int(data.get('kiosk_width_pct', 30))))
+    kiosk_height    = max(30, min(100, int(data.get('kiosk_height',   80))))
+    db.set_state('chat_enabled',          '1' if enabled else '0')
+    db.set_state('chat_position',         position)
+    db.set_state('chat_width_pct',        str(width_pct))
+    db.set_state('chat_height',           str(height))
+    db.set_state('chat_bg_color',         bg_color)
+    db.set_state('chat_bg_opacity',       str(bg_opacity))
+    db.set_state('chat_text_color',       text_color)
+    db.set_state('chat_font_size',        str(font_size))
+    db.set_state('kiosk_chat_enabled',    '1' if kiosk_enabled else '0')
+    db.set_state('kiosk_chat_position',   kiosk_position)
+    db.set_state('kiosk_chat_width',      str(kiosk_width_pct))
+    db.set_state('kiosk_chat_height',     str(kiosk_height))
+    payload = {
+        'enabled': enabled, 'position': position,
+        'width_pct': width_pct, 'height': height,
+        'bg_color': bg_color, 'bg_opacity': bg_opacity,
+        'text_color': text_color, 'font_size': font_size,
+        'kiosk_enabled': kiosk_enabled, 'kiosk_position': kiosk_position,
+        'kiosk_width_pct': kiosk_width_pct, 'kiosk_height': kiosk_height,
+    }
+    socketio.emit('chat_settings', payload)
+    return '', 204
+
+
+# --- Clock overlay ---
+
+@app.route('/api/clock', methods=['GET'])
+def get_clock():
+    state = db.get_state()
+    return jsonify({
+        'enabled':       state.get('clock_enabled',       '0') == '1',
+        'style':         state.get('clock_style',         'digital'),
+        'show_date':     state.get('clock_show_date',     '0') == '1',
+        'position':      state.get('clock_position',      'top-right'),
+        'size':          float(state.get('clock_size',    '2')),
+        'color':         state.get('clock_color',         '#ffffff'),
+        'opacity':       int(state.get('clock_opacity',   '90')),
+        'schedule_mode': state.get('clock_schedule_mode', 'always'),
+    })
+
+
+@app.route('/api/clock', methods=['POST'])
+@login_required
+def set_clock():
+    data = request.get_json()
+    if not data:
+        abort(400)
+    enabled  = bool(data.get('enabled', False))
+    style    = 'analog' if data.get('style') == 'analog' else 'digital'
+    show_date = bool(data.get('show_date', False))
+    position  = data.get('position', 'top-right')
+    if position not in {'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center'}:
+        position = 'top-right'
+    size    = max(1.0, min(5.0, float(data.get('size', 2))))
+    color   = str(data.get('color', '#ffffff'))
+    if not _HEX_COLOR.match(color):
+        color = '#ffffff'
+    opacity = max(0, min(100, int(data.get('opacity', 90))))
+    schedule_mode = 'scheduled_only' if data.get('schedule_mode') == 'scheduled_only' else 'always'
+
+    db.set_state('clock_enabled',       '1' if enabled else '0')
+    db.set_state('clock_style',         style)
+    db.set_state('clock_show_date',     '1' if show_date else '0')
+    db.set_state('clock_position',      position)
+    db.set_state('clock_size',          str(size))
+    db.set_state('clock_color',         color)
+    db.set_state('clock_opacity',       str(opacity))
+    db.set_state('clock_schedule_mode', schedule_mode)
+
+    payload = {
+        'enabled': enabled, 'style': style, 'show_date': show_date,
+        'position': position, 'size': size, 'color': color,
+        'opacity': opacity, 'schedule_mode': schedule_mode,
+    }
+    socketio.emit('clock_settings', payload)
+    return '', 204
+
+
+# --- Wi-Fi ---
+
+def _wifi_iface():
+    try:
+        with open('/proc/net/wireless') as _f:
+            for _line in _f:
+                if ':' in _line:
+                    _i = _line.split(':')[0].strip()
+                    if _i:
+                        return _i
+    except Exception:
+        pass
+    return 'wlo1'
+
+_WIFI_IFACE = _wifi_iface()
+
+
+def _wpa(*args):
+    cmd = ['sudo', '-n', '/usr/sbin/wpa_cli', '-i', _WIFI_IFACE] + list(args)
+    try:
+        r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
+        return r.returncode, r.stdout.strip()
+    except Exception as e:
+        return 1, str(e)
+
+
+def _wifi_status():
+    rc, out = _wpa('status')
+    d = {}
+    if rc == 0:
+        for line in out.splitlines():
+            if '=' in line:
+                k, v = line.split('=', 1)
+                d[k.strip()] = v.strip()
+    connected = d.get('wpa_state') == 'COMPLETED'
+    ip = ''
+    try:
+        r = subprocess.run(['ip', '-4', 'addr', 'show', _WIFI_IFACE],
+                           capture_output=True, text=True, timeout=5)
+        m = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', r.stdout)
+        if m:
+            ip = m.group(1)
+    except Exception:
+        pass
+    return {
+        'connected': connected,
+        'ssid': d.get('ssid', ''),
+        'ip': ip,
+        'signal': int(d.get('signal_level', 0) or 0),
+        'iface': _WIFI_IFACE,
+        'sudo_ok': rc != 1 or 'sudo' not in out,
+    }
+
+
+def _wifi_scan():
+    _wpa('scan')
+    import time; time.sleep(2.5)
+    rc, out = _wpa('scan_results')
+    networks = []
+    seen = set()
+    for line in out.splitlines():
+        if not line or line.startswith('bssid'):
+            continue
+        parts = line.split('\t')
+        if len(parts) < 5:
+            continue
+        ssid = parts[4].strip()
+        if not ssid or ssid in seen:
+            continue
+        seen.add(ssid)
+        try:
+            dbm = int(parts[2])
+        except (ValueError, IndexError):
+            dbm = -90
+        flags = parts[3] if len(parts) > 3 else ''
+        if 'WPA2' in flags:
+            sec = 'WPA2'
+        elif 'WPA' in flags:
+            sec = 'WPA'
+        elif 'WEP' in flags:
+            sec = 'WEP'
+        else:
+            sec = 'open'
+        networks.append({
+            'ssid': ssid,
+            'signal': max(0, min(100, 2 * (dbm + 100))),
+            'security': sec,
+        })
+    networks.sort(key=lambda x: -x['signal'])
+    return networks
+
+
+def _wifi_saved():
+    rc, out = _wpa('list_networks')
+    saved = []
+    for line in out.splitlines():
+        parts = line.split('\t')
+        if len(parts) >= 2 and parts[0].strip().isdigit():
+            ssid = parts[1].strip()
+            if ssid:
+                flags = parts[3].strip() if len(parts) > 3 else ''
+                saved.append({
+                    'id': parts[0].strip(),
+                    'ssid': ssid,
+                    'current': '[CURRENT]' in flags,
+                })
+    return saved
+
+
+def _wifi_connect(ssid, password):
+    for net in _wifi_saved():
+        if net['ssid'] == ssid:
+            _wpa('remove_network', net['id'])
+    rc, net_id = _wpa('add_network')
+    net_id = net_id.strip()
+    if not net_id.isdigit():
+        return False, 'Kunde inte lägga till nätverk'
+    _wpa('set_network', net_id, 'ssid', f'"{ssid}"')
+    if password:
+        _wpa('set_network', net_id, 'psk', f'"{password}"')
+    else:
+        _wpa('set_network', net_id, 'key_mgmt', 'NONE')
+    _wpa('select_network', net_id)
+    import time
+    for _ in range(20):
+        time.sleep(1)
+        s = _wifi_status()
+        if s['connected'] and s['ssid'] == ssid:
+            _wpa('save_config')
+            return True, s.get('ip', '')
+    _wpa('remove_network', net_id)
+    return False, 'Anslutning misslyckades (fel lösenord?)'
+
+
+@app.route('/api/wifi/status')
+@login_required
+def api_wifi_status():
+    return jsonify(_wifi_status())
+
+
+@app.route('/api/wifi/networks')
+@login_required
+def api_wifi_networks():
+    try:
+        nets = _wifi_scan()
+        known = {n['ssid'] for n in _wifi_saved()}
+        for n in nets:
+            n['known'] = n['ssid'] in known
+        return jsonify({'networks': nets, 'iface': _WIFI_IFACE})
+    except Exception as e:
+        return jsonify({'networks': [], 'error': str(e)})
+
+
+@app.route('/api/wifi/connect', methods=['POST'])
+@login_required
+def api_wifi_connect():
+    data = request.get_json()
+    if not data or not data.get('ssid'):
+        abort(400)
+    ssid     = str(data['ssid'])[:64]
+    password = str(data.get('password', ''))[:64]
+    if '"' in ssid or '"' in password:
+        return jsonify({'ok': False, 'message': 'Ogiltiga tecken i SSID/lösenord'}), 400
+    ok, msg = _wifi_connect(ssid, password)
+    return jsonify({'ok': ok, 'message': msg if not ok else '', 'ip': msg if ok else ''})
+
+
+@app.route('/api/wifi/disconnect', methods=['POST'])
+@login_required
+def api_wifi_disconnect():
+    _wpa('disconnect')
+    return '', 204
+
+
+@app.route('/api/wifi/saved')
+@login_required
+def api_wifi_saved():
+    return jsonify({'saved': _wifi_saved()})
+
+
+@app.route('/api/wifi/saved/<net_id>', methods=['DELETE'])
+@login_required
+def api_wifi_remove(net_id):
+    if not net_id.isdigit():
+        abort(400)
+    _wpa('remove_network', net_id)
+    _wpa('save_config')
+    return '', 204
+
+
+# --- System ---
+
+@app.route('/api/reboot', methods=['POST'])
+@login_required
+def reboot():
+    subprocess.Popen(['sudo', 'reboot'])
+    return '', 204
+
+
+@app.route('/api/shutdown', methods=['POST'])
+@login_required
+def shutdown():
+    subprocess.Popen(['sudo', 'poweroff'])
+    return '', 204
+
+
+def _sway_env():
+    """Returnerar env-dict med SWAYSOCK satt, eller None om sway inte körs."""
+    uid = os.getuid()
+    xdg_runtime = os.environ.get('XDG_RUNTIME_DIR', f'/run/user/{uid}')
+    sockets = sorted(Path(xdg_runtime).glob(f'sway-ipc.{uid}.*.sock'))
+    if not sockets:
+        return None
+    env = os.environ.copy()
+    env['SWAYSOCK'] = str(sockets[0])
+    return env
+
+
+@app.route('/api/restart', methods=['POST'])
+@login_required
+def restart():
+    # Avsluta sway — getty loopar och startar om via .bash_profile → kiosk-launcher.sh
+    try:
+        env = _sway_env()
+        if env:
+            subprocess.Popen(['swaymsg', 'exit'], env=env)
+    except Exception:
+        pass
+    return '', 204
+
+
+def _apply_internal_display(enabled, env):
+    """Aktivera eller stäng av eDP-1 (intern display) via sway."""
+    try:
+        if enabled:
+            subprocess.run(['swaymsg', 'output', 'eDP-1', 'enable'], env=env, timeout=5)
+            eventlet.sleep(0.5)
+            local_ip = _local_ip()
+            flags = (
+                '--kiosk --ozone-platform=wayland --noerr --no-first-run '
+                '--disable-infobars --disable-session-crashed-bubble --disable-translate '
+                '--disable-web-security --autoplay-policy=no-user-gesture-required '
+                '--no-sandbox --user-data-dir=/tmp/kiosk-admin'
+            )
+            subprocess.run(['swaymsg', 'workspace 2'], env=env, timeout=5)
+            eventlet.sleep(0.3)
+            subprocess.run(['swaymsg', 'exec', f'chromium {flags} http://{local_ip}:5000/admin'],
+                           env=env, timeout=5)
+            eventlet.sleep(1)
+            subprocess.run(['swaymsg', 'workspace 1'], env=env, timeout=5)
+        else:
+            subprocess.run(['pkill', '-f', 'user-data-dir=/tmp/kiosk-admin'], timeout=5)
+            eventlet.sleep(0.3)
+            subprocess.run(['swaymsg', 'output', 'eDP-1', 'disable'], env=env, timeout=5)
+    except Exception:
+        pass
+
+
+@app.route('/api/internal_display', methods=['POST'])
+@login_required
+def toggle_internal_display():
+    data = request.get_json()
+    if data is None:
+        abort(400)
+    enabled = bool(data.get('enabled', True))
+    val = 'on' if enabled else 'off'
+
+    # Spara till DB och kiosk.conf
+    db.set_state('internal_display', val)
+    state = db.get_state()
+    conf = Path(os.path.dirname(__file__)) / 'kiosk.conf'
+    conf.write_text(
+        '# Genererad av kiosk-admin — ändra ej manuellt\n'
+        f'KIOSK_AUDIO_PROFILE_INDEX={state.get("audio_profile_index", 1)}\n'
+        f'KIOSK_DISPLAY_OUTPUT={state.get("display_output", "HDMI-A-1")}\n'
+        f'KIOSK_INTERNAL_DISPLAY={val}\n'
+        + (f'KIOSK_DISPLAY_MODE={state["display_mode"]}\n' if state.get("display_mode") else '')
+    )
+
+    env = _sway_env()
+    if not env:
+        return '', 204
+    eventlet.spawn(_apply_internal_display, enabled, env)
+    return '', 204
+
+
+# --- Startup ---
+
+def _apply_settings_after_cage():
+    """Background task: wait for sway's Wayland socket, then apply saved display + audio."""
+    xdg_runtime = os.environ.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
+    # Wait up to 30 s for any wayland socket to appear (sway may not be running yet)
+    for _ in range(30):
+        eventlet.sleep(1)
+        socks = [p for p in Path(xdg_runtime).glob('wayland-[0-9]*') if p.suffix != '.lock']
+        if socks:
+            break
+    state = db.get_state()
+    saved_mode = state.get('display_mode', '')
+    if saved_mode:
+        _apply_display_mode(saved_mode, target_output=state.get('display_output') or None)
+    _apply_saved_audio()
+
+    # Om inget WiFi: aktivera intern display automatiskt så admin-gränssnittet är tillgängligt
+    eventlet.sleep(2)  # ge sway tid att sätta upp outputs
+    env = _sway_env()
+    if env:
+        internal_pref = state.get('internal_display', 'off')
+        wifi = _wifi_status()
+        should_enable = (not wifi['connected']) or (internal_pref == 'on')
+        if should_enable:
+            _apply_internal_display(True, env)
+
+
+if __name__ == '__main__':
+    db.init_db()
+    # Apply audio immediately (PipeWire is independent of the Wayland compositor)
+    _apply_saved_audio()
+    # Apply display settings in background after sway/Wayland is up
+    eventlet.spawn(_apply_settings_after_cage)
+    sched.init_scheduler(socketio)
+    socketio.run(app, host='0.0.0.0', port=5000)
diff --git a/kiosk/db.py b/kiosk/db.py
new file mode 100644
index 0000000..36e0480
--- /dev/null
+++ b/kiosk/db.py
@@ -0,0 +1,158 @@
+import sqlite3
+import os
+
+DB_PATH = os.path.join(os.path.dirname(__file__), 'kiosk.db')
+
+
+def get_conn():
+    conn = sqlite3.connect(DB_PATH)
+    conn.row_factory = sqlite3.Row
+    return conn
+
+
+def init_db():
+    with get_conn() as conn:
+        conn.executescript("""
+            CREATE TABLE IF NOT EXISTS jobs (
+                id       INTEGER PRIMARY KEY AUTOINCREMENT,
+                label    TEXT NOT NULL,
+                duration REAL NOT NULL,
+                type     TEXT NOT NULL,
+                source   TEXT NOT NULL,
+                enabled  INTEGER DEFAULT 1,
+                position INTEGER
+            );
+
+            CREATE TABLE IF NOT EXISTS state (
+                key   TEXT PRIMARY KEY,
+                value TEXT
+            );
+        """)
+        # Migrering: time → duration
+        cols = {r[1]: r[2] for r in conn.execute("PRAGMA table_info(jobs)").fetchall()}
+        if 'time' in cols and 'duration' not in cols:
+            conn.executescript("""
+                ALTER TABLE jobs ADD COLUMN duration REAL NOT NULL DEFAULT 5;
+                UPDATE jobs SET duration = 5;
+            """)
+        # Migrering: add position column if missing
+        if 'position' not in cols:
+            conn.executescript("""
+                ALTER TABLE jobs RENAME TO jobs_old;
+                CREATE TABLE jobs (
+                    id       INTEGER PRIMARY KEY AUTOINCREMENT,
+                    label    TEXT NOT NULL,
+                    duration REAL NOT NULL,
+                    type     TEXT NOT NULL,
+                    source   TEXT NOT NULL,
+                    enabled  INTEGER DEFAULT 1,
+                    position INTEGER
+                );
+                INSERT INTO jobs (id, label, duration, type, source, enabled) SELECT id, label, duration, type, source, enabled FROM jobs_old;
+                DROP TABLE jobs_old;
+            """)
+            # initialize positions to id
+            conn.execute("UPDATE jobs SET position = id;")
+        # Migrering: INTEGER → REAL
+        elif cols.get('duration') == 'INTEGER':
+            conn.executescript("""
+                ALTER TABLE jobs RENAME TO jobs_old;
+                CREATE TABLE jobs (
+                    id       INTEGER PRIMARY KEY AUTOINCREMENT,
+                    label    TEXT NOT NULL,
+                    duration REAL NOT NULL,
+                    type     TEXT NOT NULL,
+                    source   TEXT NOT NULL,
+                    enabled  INTEGER DEFAULT 1
+                );
+                INSERT INTO jobs SELECT * FROM jobs_old;
+                DROP TABLE jobs_old;
+            """)
+
+
+# --- Jobs ---
+
+def get_all_jobs():
+    with get_conn() as conn:
+        return [dict(r) for r in conn.execute("SELECT * FROM jobs ORDER BY COALESCE(position, id), id").fetchall()]
+
+
+def get_enabled_jobs():
+    with get_conn() as conn:
+        return [dict(r) for r in conn.execute(
+            "SELECT * FROM jobs WHERE enabled = 1 ORDER BY COALESCE(position, id), id"
+        ).fetchall()]
+
+
+def add_job(label, duration, type_, source):
+    with get_conn() as conn:
+        # determine next position
+        curpos = conn.execute("SELECT MAX(position) AS m FROM jobs").fetchone()
+        nextpos = (curpos['m'] or 0) + 1
+        cur = conn.execute(
+            "INSERT INTO jobs (label, duration, type, source, position) VALUES (?, ?, ?, ?, ?)",
+            (label, duration, type_, source, nextpos)
+        )
+        return cur.lastrowid
+
+
+def delete_job(job_id):
+    with get_conn() as conn:
+        conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
+
+
+def toggle_job(job_id):
+    with get_conn() as conn:
+        conn.execute(
+            "UPDATE jobs SET enabled = CASE WHEN enabled = 1 THEN 0 ELSE 1 END WHERE id = ?",
+            (job_id,)
+        )
+        row = conn.execute("SELECT enabled FROM jobs WHERE id = ?", (job_id,)).fetchone()
+        return dict(row) if row else None
+
+
+def update_job(job_id, **fields):
+    if not fields:
+        return
+    keys = []
+    vals = []
+    for k, v in fields.items():
+        if k in ('label', 'duration', 'type', 'source', 'enabled'):
+            keys.append(f"{k} = ?")
+            vals.append(v)
+    if not keys:
+        return
+    vals.append(job_id)
+    with get_conn() as conn:
+        conn.execute(f"UPDATE jobs SET {', '.join(keys)} WHERE id = ?", tuple(vals))
+
+
+def reorder_jobs(id_list):
+    with get_conn() as conn:
+        for idx, jid in enumerate(id_list, start=1):
+            conn.execute("UPDATE jobs SET position = ? WHERE id = ?", (idx, jid))
+
+
+# --- State ---
+
+def set_state(key, value):
+    with get_conn() as conn:
+        conn.execute(
+            "INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)", (key, value)
+        )
+
+
+def set_current(type_, source):
+    with get_conn() as conn:
+        conn.execute(
+            "INSERT OR REPLACE INTO state (key, value) VALUES ('current_type', ?)", (type_,)
+        )
+        conn.execute(
+            "INSERT OR REPLACE INTO state (key, value) VALUES ('current_source', ?)", (source,)
+        )
+
+
+def get_state():
+    with get_conn() as conn:
+        rows = conn.execute("SELECT key, value FROM state").fetchall()
+        return {r['key']: r['value'] for r in rows}
diff --git a/kiosk/hdmi-monitor.sh b/kiosk/hdmi-monitor.sh
new file mode 100755
index 0000000..f67324f
--- /dev/null
+++ b/kiosk/hdmi-monitor.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# hdmi-monitor.sh — körs i bakgrunden under sway-sessionen.
+# Avslutar sway om HDMI-A-1 kopplas ur så att kiosk-display.service
+# startar om och väntar på att kabeln kopplas in igen.
+
+STATUS="/sys/class/drm/card0-HDMI-A-1/status"
+
+while sleep 4; do
+  if [ "$(cat "$STATUS" 2>/dev/null)" != "connected" ]; then
+    echo "HDMI-A-1 frånkopplad — avslutar sway" >&2
+    swaymsg exit
+    break
+  fi
+done
diff --git a/kiosk/hdmi-wait.sh b/kiosk/hdmi-wait.sh
new file mode 100755
index 0000000..f5f62fe
--- /dev/null
+++ b/kiosk/hdmi-wait.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# hdmi-wait.sh — väntar tills HDMI-A-1 är ansluten, skriver meddelande till tty
+
+STATUS="/sys/class/drm/card0-HDMI-A-1/status"
+
+if [ "$(cat "$STATUS" 2>/dev/null)" = "connected" ]; then
+  exit 0
+fi
+
+echo ""
+echo "  ╔══════════════════════════════════════════════════════╗"
+echo "  ║   Anslut HDMI-kabeln till utgång HDMI-A-1            ║"
+echo "  ║   Kiosken startar automatiskt när kabeln är klar.    ║"
+echo "  ╚══════════════════════════════════════════════════════╝"
+echo ""
+
+while [ "$(cat "$STATUS" 2>/dev/null)" != "connected" ]; do
+  sleep 2
+done
+
+echo "  HDMI-A-1 ansluten — startar kiosk..."
diff --git a/kiosk/kiosk-display.service b/kiosk/kiosk-display.service
new file mode 100644
index 0000000..1b8a47c
--- /dev/null
+++ b/kiosk/kiosk-display.service
@@ -0,0 +1,23 @@
+[Unit]
+Description=Kiosk Display (Sway + Chromium)
+After=kiosk-server.service
+Requires=kiosk-server.service
+StartLimitIntervalSec=0
+
+[Service]
+User=mrfox
+Group=mrfox
+PAMName=login
+Environment=XDG_RUNTIME_DIR=/run/user/1000
+Environment=HOME=/home/mrfox
+TTYPath=/dev/tty1
+StandardInput=tty
+StandardOutput=tty
+StandardError=journal
+TimeoutStartSec=infinity
+ExecStart=/home/mrfox/codes/display/kiosk/kiosk-launcher.sh
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/kiosk/kiosk-launcher.sh b/kiosk/kiosk-launcher.sh
new file mode 100755
index 0000000..10595fa
--- /dev/null
+++ b/kiosk/kiosk-launcher.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# kiosk-launcher.sh — läser kiosk.conf, genererar sway output-config och startar sway.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CONF="$SCRIPT_DIR/kiosk.conf"
+SWAY_CONF="$SCRIPT_DIR/sway.conf"
+OUTPUT_CONF="/tmp/kiosk-sway-outputs.conf"
+HDMI_STATUS="/sys/class/drm/card0-HDMI-A-1/status"
+
+# Vänta på HDMI-A-1 här — vi är nu inuti TTY-kontexten och kan skriva till tty1
+while [ "$(cat "$HDMI_STATUS" 2>/dev/null)" != "connected" ]; do
+  echo "  Anslut HDMI-kabeln (HDMI-A-1) — kiosken startar automatiskt..."
+  sleep 3
+done
+echo "  HDMI-A-1 ansluten — startar..."
+
+# Starta Flask om den inte redan svarar
+if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:5000/display 2>/dev/null; then
+  bash "$SCRIPT_DIR/start.sh" &
+fi
+
+# Vänta tills kiosk-servern svarar
+until curl -sf -o /dev/null http://127.0.0.1:5000/display 2>/dev/null; do sleep 1; done
+
+# Vänta tills WiFi har en giltig IP (max 40 s) — annars visar idle-skärmen fel adress
+for _i in $(seq 1 40); do
+  _ip=$(ip route get 8.8.8.8 2>/dev/null \
+    | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}')
+  [ -n "$_ip" ] && [ "$_ip" != "127.0.0.1" ] && break
+  sleep 1
+done
+unset _i _ip
+
+# Standardvärden
+KIOSK_DISPLAY_OUTPUT="HDMI-A-1"
+KIOSK_DISPLAY_MODE="1920x1080@60Hz"
+KIOSK_INTERNAL_DISPLAY="on"
+
+if [ -f "$CONF" ]; then
+  # shellcheck disable=SC1090
+  . "$CONF"
+fi
+
+# Extrahera WxH och Hz (t.ex. "1280x720@60.000000" → WH="1280x720" HZ="60")
+RAW_MODE="${KIOSK_DISPLAY_MODE:-1920x1080}"
+WH="${RAW_MODE%%@*}"
+HZ_RAW="${RAW_MODE##*@}"
+HZ="${HZ_RAW%%.*}"
+[ -z "$HZ" ] && HZ="60"
+
+KIOSK_OUT="${KIOSK_DISPLAY_OUTPUT:-HDMI-A-1}"
+KIOSK_W="${WH%%x*}"
+
+# Generera output-config för sway
+if [ "$KIOSK_INTERNAL_DISPLAY" = "on" ]; then
+  cat > "$OUTPUT_CONF" <<EOF
+output $KIOSK_OUT  mode ${WH}@${HZ}Hz pos 0 0 enable
+output eDP-1       mode 1920x1080@60Hz pos ${KIOSK_W},0 enable
+
+workspace 1 output $KIOSK_OUT
+workspace 2 output eDP-1
+EOF
+else
+  cat > "$OUTPUT_CONF" <<EOF
+output $KIOSK_OUT  mode ${WH}@${HZ}Hz pos 0 0 enable
+output eDP-1       disable
+
+workspace 1 output $KIOSK_OUT
+EOF
+fi
+
+exec /usr/bin/sway -c "$SWAY_CONF"
diff --git a/kiosk/plans.md b/kiosk/plans.md
new file mode 100644
index 0000000..e69de29
diff --git a/kiosk/scheduler.py b/kiosk/scheduler.py
new file mode 100644
index 0000000..44731cf
--- /dev/null
+++ b/kiosk/scheduler.py
@@ -0,0 +1,85 @@
+from datetime import datetime, timedelta
+from apscheduler.schedulers.background import BackgroundScheduler
+import db
+
+scheduler = BackgroundScheduler()
+_socketio = None
+_current_index = 0
+
+
+def init_scheduler(socketio):
+    global _socketio
+    _socketio = socketio
+    scheduler.start()
+    reload_jobs()
+    reload_sleep_jobs()
+
+
+def reload_jobs():
+    global _current_index
+    _current_index = 0
+    try:
+        scheduler.remove_job('rotation')
+    except Exception:
+        pass
+    jobs = db.get_enabled_jobs()
+    if not jobs:
+        return
+    _emit_and_schedule(jobs[0])
+
+
+def _advance():
+    global _current_index
+    jobs = db.get_enabled_jobs()
+    if not jobs:
+        return
+    _current_index = (_current_index + 1) % len(jobs)
+    _emit_and_schedule(jobs[_current_index])
+
+
+def _emit_and_schedule(job):
+    if _socketio:
+        _socketio.emit('switch', {'type': job['type'], 'source': job['source'], 'scheduled': True})
+        db.set_current(job['type'], job['source'])
+    scheduler.add_job(
+        func=_advance,
+        trigger='date',
+        run_date=datetime.now() + timedelta(minutes=job['duration']),
+        id='rotation',
+        replace_existing=True
+    )
+
+
+# --- Söv-läge ---
+
+def _sleep_on():
+    if _socketio:
+        _socketio.emit('sleep_toggle', {'active': True})
+
+
+def _sleep_off():
+    if _socketio:
+        _socketio.emit('sleep_toggle', {'active': False})
+
+
+def reload_sleep_jobs():
+    for jid in ('sleep_on', 'sleep_off'):
+        try:
+            scheduler.remove_job(jid)
+        except Exception:
+            pass
+
+    state = db.get_state()
+    if state.get('sleep_enabled') != '1':
+        return
+
+    start = state.get('sleep_start', '22:00')
+    end   = state.get('sleep_end',   '07:00')
+    try:
+        sh, sm = map(int, start.split(':'))
+        eh, em = map(int, end.split(':'))
+    except Exception:
+        return
+
+    scheduler.add_job(_sleep_on,  'cron', hour=sh, minute=sm, id='sleep_on',  replace_existing=True)
+    scheduler.add_job(_sleep_off, 'cron', hour=eh, minute=em, id='sleep_off', replace_existing=True)
diff --git a/kiosk/start.sh b/kiosk/start.sh
new file mode 100755
index 0000000..2408297
--- /dev/null
+++ b/kiosk/start.sh
@@ -0,0 +1,121 @@
+#!/usr/bin/env bash
+# start.sh — apply KIOSK_DISPLAY_MODE (best-effort) then launch the kiosk app
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CONF="$SCRIPT_DIR/kiosk.conf"
+
+if [ -f "$CONF" ]; then
+  # shellcheck disable=SC1090
+  . "$CONF"
+fi
+
+MODE="${KIOSK_DISPLAY_MODE:-}"
+
+apply_mode() {
+  local mode="$1"
+  # Accept WxH or WxH@Hz (e.g. 1280x720 or 1280x720@60.000000)
+  if [[ ! "$mode" =~ ^([0-9]+)x([0-9]+) ]]; then
+    echo "[start.sh] No valid mode specified: '$mode'"
+    return 1
+  fi
+  local W=${BASH_REMATCH[1]}
+  local H=${BASH_REMATCH[2]}
+  # Strip @Hz suffix for tools that don't accept it
+  local WH="${W}x${H}"
+  echo "[start.sh] Applying display mode $mode (best-effort)"
+
+  # Try X11 xrandr
+  if command -v xrandr >/dev/null 2>&1; then
+    OUT=$(xrandr --query 2>/dev/null | awk '/ connected/{print $1; exit}')
+    if [ -n "$OUT" ]; then
+      echo "[start.sh] Using xrandr on output $OUT"
+      if xrandr --output "$OUT" --mode "$WH" 2>/dev/null; then
+        echo "[start.sh] xrandr succeeded"
+        return 0
+      else
+        echo "[start.sh] xrandr failed to set mode"
+      fi
+    fi
+  fi
+
+  # Try sway (Wayland)
+  if command -v swaymsg >/dev/null 2>&1; then
+    echo "[start.sh] Trying swaymsg"
+    if swaymsg "output * mode ${W} ${H}" >/dev/null 2>&1; then
+      echo "[start.sh] swaymsg succeeded"
+      return 0
+    else
+      echo "[start.sh] swaymsg failed"
+    fi
+  fi
+
+  # Try wlr-randr
+  if command -v wlr-randr >/dev/null 2>&1; then
+    OUT=$(wlr-randr 2>/dev/null | awk 'NR==1{print $1}')
+    if [ -n "$OUT" ]; then
+      echo "[start.sh] Using wlr-randr on output $OUT"
+      if wlr-randr --output "$OUT" --mode "$WH" >/dev/null 2>&1; then
+        echo "[start.sh] wlr-randr succeeded"
+        return 0
+      else
+        echo "[start.sh] wlr-randr failed"
+      fi
+    fi
+  fi
+
+  echo "[start.sh] No supported display tool succeeded (this is best-effort)."
+  return 1
+}
+
+apply_audio() {
+  local index="$1"
+  if [[ ! "$index" =~ ^[0-9]+$ ]]; then
+    return 1
+  fi
+  if ! command -v pw-cli >/dev/null 2>&1; then
+    return 1
+  fi
+  echo "[start.sh] Applying audio profile index $index (best-effort)"
+  local dev_id
+  dev_id=$(pw-dump 2>/dev/null | python3 -c "
+import sys, json
+try:
+    for n in json.load(sys.stdin):
+        if n.get('type') == 'PipeWire:Interface:Device':
+            props = n.get('info', {}).get('props', {})
+            if props.get('media.class') == 'Audio/Device':
+                print(n['id'])
+                break
+except Exception:
+    pass
+" 2>/dev/null)
+  if [ -n "$dev_id" ]; then
+    if pw-cli s "$dev_id" Profile "{ index: $index }" >/dev/null 2>&1; then
+      echo "[start.sh] Audio profile applied (device $dev_id, index $index)"
+      return 0
+    else
+      echo "[start.sh] pw-cli failed to set audio profile"
+    fi
+  else
+    echo "[start.sh] No audio device found via pw-dump"
+  fi
+  return 1
+}
+
+if [ -n "$MODE" ]; then
+  apply_mode "$MODE" || echo "[start.sh] apply_mode failed, continuing to start app"
+else
+  echo "[start.sh] No KIOSK_DISPLAY_MODE set in $CONF"
+fi
+
+AUDIO_INDEX="${KIOSK_AUDIO_PROFILE_INDEX:-}"
+if [ -n "$AUDIO_INDEX" ]; then
+  apply_audio "$AUDIO_INDEX" || echo "[start.sh] apply_audio failed, continuing to start app"
+else
+  echo "[start.sh] No KIOSK_AUDIO_PROFILE_INDEX set in $CONF"
+fi
+
+cd "$SCRIPT_DIR"
+
+echo "[start.sh] Launching kiosk app"
+exec python3 app.py
diff --git a/kiosk/static/admin.js b/kiosk/static/admin.js
new file mode 100644
index 0000000..fc5ed01
--- /dev/null
+++ b/kiosk/static/admin.js
@@ -0,0 +1,1108 @@
+const socket = io();
+
+// --- Fetch-hjälpare med felhantering ---
+
+async function apiFetch(url, options = {}) {
+  const res = await fetch(url, options);
+  if (res.redirected && res.url.includes('/login')) {
+    window.location.href = '/login';
+    return null;
+  }
+  if (!res.ok) throw new Error(`HTTP ${res.status}`);
+  return res;
+}
+
+// --- Visa nu ---
+
+function visaAdress() {
+  let src = document.getElementById('url-input').value.trim();
+  if (!src) return;
+  if (!/^https?:\/\//i.test(src)) src = 'https://' + src;
+  const embed = youtubeEmbedUrl(src);
+  if (embed) {
+    socket.emit('admin_switch', { type: 'url', source: embed });
+  } else {
+    socket.emit('admin_switch', { type: 'url', source: src });
+  }
+}
+
+function extractYouTubeId(url) {
+  const patterns = [
+    /[?&]v=([a-zA-Z0-9_-]{11})/,
+    /youtu\.be\/([a-zA-Z0-9_-]{11})/,
+    /embed\/([a-zA-Z0-9_-]{11})/,
+  ];
+  for (const p of patterns) {
+    const m = url.match(p);
+    if (m) return m[1];
+  }
+  return null;
+}
+
+function youtubeEmbedUrl(url) {
+  const id = extractYouTubeId(url);
+  if (!id) return null;
+  return `https://www.youtube.com/embed/${id}?autoplay=1`;
+}
+
+
+function visaLokalFil(path, type) {
+  const src = '/files?path=' + encodeURIComponent(path);
+  socket.emit('admin_switch', { type, source: src });
+}
+
+async function setSchemaCalla(path, type) {
+  const form = document.getElementById('job-form');
+  const url = '/files?path=' + encodeURIComponent(path);
+  form.source.value = url;
+  form.type.value = type;
+  if (type === 'video') {
+    const mins = await getVideoDuration(url);
+    if (mins !== null) form.duration.value = mins;
+  }
+}
+
+function getVideoDuration(url) {
+  return new Promise((resolve) => {
+    const v = document.createElement('video');
+    v.preload = 'metadata';
+    v.onloadedmetadata = () => {
+      const mins = Math.ceil(v.duration / 60 * 100) / 100;
+      v.src = '';
+      resolve(mins);
+    };
+    v.onerror = () => resolve(null);
+    v.src = url;
+  });
+}
+
+function laddaUpp() {
+  const input = document.getElementById('upload-input');
+  const file = input.files[0];
+  if (!file) return;
+
+  const fill   = document.getElementById('progress-fill');
+  const status = document.getElementById('upload-status');
+  const bar    = document.getElementById('upload-progress');
+
+  fill.style.width = '0%';
+  status.textContent = 'Laddar upp...';
+  bar.style.display = 'flex';
+
+  const xhr = new XMLHttpRequest();
+
+  xhr.upload.onprogress = (e) => {
+    if (e.lengthComputable) {
+      fill.style.width = Math.round(e.loaded / e.total * 100) + '%';
+    }
+  };
+
+  xhr.onload = () => {
+    input.value = '';
+    if (xhr.status === 200) {
+      const data = JSON.parse(xhr.responseText);
+      fill.style.width = '100%';
+      status.textContent = '✓ ' + data.path.split('/').pop();
+      socket.emit('admin_switch', { type: extType(data.path), source: data.url });
+      setTimeout(() => { bar.style.display = 'none'; }, 3000);
+    } else {
+      status.textContent = 'Fel ' + xhr.status;
+    }
+  };
+
+  xhr.onerror = () => { status.textContent = 'Nätverksfel'; };
+  const fd = new FormData();
+  fd.append('file', file);
+  xhr.open('POST', '/api/upload');
+  xhr.send(fd);
+}
+
+async function laddaOmDisplay() {
+  const res = await apiFetch('/api/sysinfo');
+  if (!res) return;
+  const d = await res.json();
+  if (d.active_type && d.active_type !== '—') {
+    socket.emit('admin_switch', { type: d.active_type, source: d.active_source });
+  }
+}
+
+function tomSkarm() {
+  socket.emit('admin_switch', { type: 'local', source: '', html: '' });
+}
+
+// --- System ---
+
+async function startaOmProgram() {
+  if (!confirm('Starta om kiosken?')) return;
+  await apiFetch('/api/restart', { method: 'POST' });
+}
+
+async function startaOmSystemet() {
+  if (!confirm('Starta om hela datorn?')) return;
+  await apiFetch('/api/reboot', { method: 'POST' });
+}
+
+async function stangAvDatorn() {
+  if (!confirm('Stäng av datorn?')) return;
+  await apiFetch('/api/shutdown', { method: 'POST' });
+}
+
+// --- Schema ---
+
+async function laddaJobb() {
+  const res = await apiFetch('/api/jobs');
+  if (!res) return;
+  const jobs = await res.json();
+  const tbody = document.getElementById('jobs-body');
+
+  if (jobs.length === 0) {
+    tbody.innerHTML = '<tr><td colspan="7" class="empty">Inga schemalagda jobb</td></tr>';
+    return;
+  }
+
+  tbody.innerHTML = jobs.map(j => `
+    <tr id="job-${j.id}" data-id="${j.id}">
+      <td>${escHtml(j.label)}</td>
+      <td>${fmtDuration(j.duration)}</td>
+      <td>${escHtml(j.type)}</td>
+      <td class="source-cell">${escHtml(j.source)}</td>
+      <td>${j.show_chat ? '✓' : '—'}</td>
+      <td>
+        <button class="toggle ${j.enabled ? 'on' : 'off'}" onclick="toggleJobb(${j.id})">
+          ${j.enabled ? 'Ja' : 'Nej'}
+        </button>
+      </td>
+      <td>
+        <button class="secondary" onclick="openEdit(${j.id})">Redigera</button>
+        <button class="secondary" onclick="moveJob(${j.id}, 'up')">▲</button>
+        <button class="secondary" onclick="moveJob(${j.id}, 'down')">▼</button>
+        <button class="danger" onclick="taBortJobb(${j.id})">X</button>
+      </td>
+    </tr>
+  `).join('');
+  // enable drag-and-drop ordering
+  makeRowsDraggable(tbody);
+}
+
+function makeRowsDraggable(tbody) {
+  let dragSrc = null;
+  const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
+  rows.forEach(row => {
+    row.draggable = true;
+    row.addEventListener('dragstart', (e) => {
+      dragSrc = row;
+      e.dataTransfer.effectAllowed = 'move';
+    });
+    row.addEventListener('dragover', (e) => {
+      e.preventDefault();
+      e.dataTransfer.dropEffect = 'move';
+    });
+    row.addEventListener('drop', (e) => {
+      e.preventDefault();
+      if (!dragSrc || dragSrc === row) return;
+      const nodes = Array.from(tbody.querySelectorAll('tr[data-id]'));
+      const srcIdx = nodes.indexOf(dragSrc);
+      const dstIdx = nodes.indexOf(row);
+      if (srcIdx < dstIdx) tbody.insertBefore(dragSrc, row.nextSibling);
+      else tbody.insertBefore(dragSrc, row);
+      sendReorder(tbody);
+    });
+    row.addEventListener('dragend', () => { dragSrc = null; });
+  });
+}
+
+function sendReorder(tbody) {
+  const newOrder = Array.from(tbody.querySelectorAll('tr[data-id]')).map(r => parseInt(r.dataset.id, 10));
+  fetch('/api/jobs/reorder', {
+    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: newOrder })
+  }).then(r => { if (r.ok) laddaJobb(); });
+}
+
+async function laggTillJobb(e) {
+  e.preventDefault();
+  const form = e.target;
+  let type = form.type.value;
+  let source = form.source.value;
+
+  if (type === 'youtube') {
+    const embed = youtubeEmbedUrl(source);
+    if (!embed) { alert('Ogiltig YouTube-URL'); return; }
+    type = 'url';
+    source = embed;
+  } else if (type === 'kiosk_chat') {
+    source = '/chat-display';
+  }
+
+  const data = {
+    label:     form.label.value,
+    duration:  form.duration.value,
+    type,
+    source,
+    show_chat: form.show_chat.checked,
+  };
+  const res = await apiFetch('/api/jobs', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(data),
+  });
+  if (!res) return;
+  form.reset();
+  laddaJobb();
+}
+
+// --- Edit job ---
+function openEdit(id) {
+  const row = document.getElementById('job-' + id);
+  if (!row) return;
+  // fetch job data from server (or parse from row)
+  fetch('/api/jobs').then(r => r.json()).then(jobs => {
+    const job = jobs.find(x => x.id === id);
+    if (!job) return;
+    const modal = document.getElementById('edit-modal');
+    const form = document.getElementById('edit-form');
+    form.id.value = job.id;
+    form.label.value = job.label;
+    form.duration.value = job.duration;
+    form.type.value = job.type;
+    form.source.value = job.source;
+    modal.style.display = 'flex';
+  });
+}
+
+function closeEditModal() {
+  document.getElementById('edit-modal').style.display = 'none';
+}
+
+function editModalClick(e) {
+  if (e.target === document.getElementById('edit-modal')) closeEditModal();
+}
+
+async function saveEdit(e) {
+  e.preventDefault();
+  const form = e.target;
+  const id = parseInt(form.id.value, 10);
+  const payload = {
+    label: form.label.value,
+    duration: parseFloat(form.duration.value),
+    type: form.type.value,
+    source: form.source.value
+  };
+  const res = await apiFetch('/api/jobs/' + id, {
+    method: 'PATCH',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(payload)
+  });
+  if (!res) return;
+  closeEditModal();
+  laddaJobb();
+}
+
+// --- Reorder ---
+function moveJob(id, dir) {
+  const tbody = document.getElementById('jobs-body');
+  const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
+  const idx = rows.findIndex(r => String(r.dataset.id) === String(id));
+  if (idx === -1) return;
+  let newIdx = dir === 'up' ? idx - 1 : idx + 1;
+  if (newIdx < 0 || newIdx >= rows.length) return;
+  // swap nodes
+  const row = rows[idx];
+  const ref = rows[newIdx];
+  if (dir === 'up') tbody.insertBefore(row, ref);
+  else tbody.insertBefore(ref, row);
+  // send new order to server
+  const newOrder = Array.from(tbody.querySelectorAll('tr[data-id]')).map(r => parseInt(r.dataset.id, 10));
+  fetch('/api/jobs/reorder', {
+    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: newOrder })
+  }).then(r => { if (r.ok) laddaJobb(); });
+}
+
+async function taBortJobb(id) {
+  const res = await apiFetch('/api/jobs/' + id, { method: 'DELETE' });
+  if (!res) return;
+  laddaJobb();
+}
+
+async function toggleJobb(id) {
+  const res = await apiFetch('/api/jobs/' + id, { method: 'PATCH' });
+  if (!res) return;
+  laddaJobb();
+}
+
+// --- Inställningar ---
+
+async function laddaVolym() {
+  const res = await apiFetch('/api/volume');
+  if (!res) return;
+  const d = await res.json();
+  const slider = document.getElementById('setting-volume');
+  slider.value = d.volume;
+  document.getElementById('volume-val').textContent = d.volume + '%';
+}
+
+async function setVolym() {
+  const vol = parseInt(document.getElementById('setting-volume').value, 10);
+  await apiFetch('/api/volume', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ volume: vol }),
+  });
+}
+
+async function laddaInstallningar() {
+  const [setRes, audioRes, dispRes] = await Promise.all([
+    apiFetch('/api/settings'),
+    apiFetch('/api/audio/profiles'),
+    apiFetch('/api/displays'),
+  ]);
+  if (!setRes || !audioRes || !dispRes) return;
+
+  const settings  = await setRes.json();
+  const profiles  = await audioRes.json();
+  const displays  = await dispRes.json();
+
+  const audioSel = document.getElementById('setting-audio');
+  audioSel.innerHTML = profiles.map(p => {
+    const label = escHtml(p.description || p.name) + (p.available === false ? ' (frånkopplad)' : '');
+    return `<option value="${p.index}"${p.index === settings.audio_profile_index ? ' selected' : ''}>${label}</option>`;
+  }).join('');
+
+  document.getElementById('setting-internal-display').checked = settings.internal_display !== 'off';
+
+  // populate display target select with available outputs + special choices
+  const dispSel = document.getElementById('setting-display');
+  const outOptions = [{val:'last',label:'Sista anslutna (standard)'},{val:'first',label:'Första anslutna'}];
+  displays.forEach(d => outOptions.push({val:d.name,label:d.name + (d.connected ? ' (ansluten)' : ' (ej ansluten)')}));
+  dispSel.innerHTML = outOptions.map(o => `<option value="${o.val}"${o.val===settings.display_output? ' selected':''}>${escHtml(o.label)}</option>`).join('');
+
+  // build a mapping name -> modes/current
+  const modesByOutput = {};
+  displays.forEach(d => { modesByOutput[d.name] = {modes: Array.isArray(d.modes)?d.modes:[], current: d.current_mode || ''}; });
+
+  const modeSel = document.getElementById('setting-display-mode');
+  const customInput = document.getElementById('setting-display-mode-custom');
+
+    function formatLabel(token) {
+      // token like '1920x1080@60.000000' -> '1920x1080 @ 60 Hz'
+      if (!token) return '';
+      if (token === '__custom__') return 'Ange manuellt...';
+      const parts = token.split('@');
+      const hz = parts[1] ? String(Math.round(parseFloat(parts[1])*1000)/1000).replace(/\.0+$/,'') : '';
+      return parts[0] + (hz ? (' @ ' + hz + ' Hz') : '');
+    }
+
+    function populateModesForOutput(outName) {
+    const info = modesByOutput[outName] || {modes:[], current:''};
+    const modes = info.modes.slice();
+    const currentMode = settings.display_mode || info.current || '';
+    if (currentMode && !modes.includes(currentMode)) modes.unshift(currentMode);
+    // sort by resolution descending (compare token prefix)
+    modes.sort((a,b)=>{const A=a.split('@')[0].split('x').map(Number),B=b.split('@')[0].split('x').map(Number);return B[0]-A[0]||B[1]-A[1];});
+    modeSel.innerHTML = '<option value="">(Ingen / lämna tomt)</option>' + modes.map(m => `<option value="${m}">${formatLabel(m)}${m===info.current? ' (aktuell)':''}</option>`).join('') + `<option value="__custom__">${formatLabel('__custom__')}</option>`;
+    // set selected
+    if (currentMode) {
+      if (modes.includes(currentMode)) { modeSel.value = currentMode; customInput.style.display='none'; }
+      else { modeSel.value='__custom__'; customInput.style.display=''; customInput.value = currentMode; }
+    } else { modeSel.value = ''; customInput.style.display='none'; }
+  }
+
+  // initial populate for selected display
+  const initialOut = dispSel.value && dispSel.value !== 'last' && dispSel.value !== 'first' ? dispSel.value : (displays.find(d=>d.connected)?.name || '');
+  if (initialOut) populateModesForOutput(initialOut);
+  dispSel.addEventListener('change', () => {
+    const v = dispSel.value;
+    if (v === 'last' || v === 'first') {
+      // choose connected first/last
+      const candidate = v === 'last' ? (displays.slice().reverse().find(d=>d.connected) || displays.find(d=>d.connected)) : (displays.find(d=>d.connected) || null);
+      populateModesForOutput(candidate ? candidate.name : '');
+    } else {
+      populateModesForOutput(v);
+    }
+  });
+  modeSel.addEventListener('change', () => {
+    if (modeSel.value === '__custom__') { customInput.style.display=''; customInput.focus(); } else { customInput.style.display='none'; }
+  });
+
+  const connected    = displays.filter(d => d.connected).map(d => d.name).join(', ');
+  const disconnected = displays.filter(d => !d.connected).map(d => d.name).join(', ');
+  const parts = [];
+  if (connected)    parts.push('Anslutna: ' + connected);
+  if (disconnected) parts.push('Ej anslutna: ' + disconnected);
+  document.getElementById('displays-info').textContent = parts.join('  |  ');
+}
+
+async function setInternDisplay(enabled) {
+  const status = document.getElementById('settings-status');
+  status.textContent = enabled ? 'Aktiverar intern display...' : 'Inaktiverar intern display...';
+  const res = await apiFetch('/api/internal_display', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ enabled }),
+  });
+  if (!res) return;
+  status.textContent = enabled ? '✓ Intern display aktiverad' : '✓ Intern display inaktiverad';
+  setTimeout(() => { status.textContent = ''; }, 3000);
+}
+
+async function sparaInstallningar() {
+  const profileIndex = parseInt(document.getElementById('setting-audio').value, 10);
+  const display      = document.getElementById('setting-display').value;
+  const modeSelEl = document.getElementById('setting-display-mode');
+  let displayMode = '';
+  if (modeSelEl.value === '__custom__') {
+    displayMode = document.getElementById('setting-display-mode-custom').value.trim();
+  } else {
+    displayMode = modeSelEl.value.trim();
+  }
+  const status       = document.getElementById('settings-status');
+
+  status.textContent = 'Tillämpar...';
+  const res = await apiFetch('/api/settings', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ audio_profile_index: profileIndex, display_output: display, display_mode: displayMode, internal_display: document.getElementById('setting-internal-display').checked }),
+  });
+  if (!res) return;
+  status.textContent = '✓ Tillämpat';
+  setTimeout(() => { status.textContent = ''; }, 3000);
+}
+
+async function applyDisplayNow() {
+  const modeSelEl = document.getElementById('setting-display-mode');
+  let displayMode = '';
+  if (modeSelEl.value === '__custom__') {
+    displayMode = document.getElementById('setting-display-mode-custom').value.trim();
+  } else {
+    displayMode = modeSelEl.value.trim();
+  }
+  if (!displayMode) { alert('Ange en upplösning först.'); return; }
+  const res = await apiFetch('/api/settings/apply_display', {
+    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_mode: displayMode, display_output: document.getElementById('setting-display').value })
+  }).catch(() => null);
+  if (!res) { alert('Misslyckades att kontakta servern'); return; }
+  const j = await res.json();
+  if (j.ok) alert('Upplösning tillämpad (försök att ändra skärm).');
+  else alert('Kunde inte tillämpa upplösning: ' + (j.error || 'okänt fel'));
+}
+
+// --- Chat overlay ---
+
+async function laddaChat() {
+  const res = await apiFetch('/api/chat');
+  if (!res) return;
+  const d = await res.json();
+
+  document.getElementById('chat-position').value = d.position || 'right';
+  document.getElementById('chat-bg-color').value = d.bg_color || '#000000';
+
+  const wp = d.width_pct  != null ? d.width_pct  : 25;
+  const h  = d.height     != null ? d.height     : 100;
+  const bo = d.bg_opacity != null ? d.bg_opacity : 70;
+
+  document.getElementById('chat-width-pct').value      = wp; document.getElementById('chat-width-pct-val').textContent = wp + '%';
+  document.getElementById('chat-height').value         = h;  document.getElementById('chat-height-val').textContent    = h  + '%';
+  document.getElementById('chat-bg-opacity').value     = bo; document.getElementById('chat-bg-opacity-val').textContent = bo + '%';
+  document.getElementById('chat-status').textContent   = d.enabled ? '● Aktiv' : '';
+
+  // Kiosk livechat
+  const kw = d.kiosk_width_pct != null ? d.kiosk_width_pct : 30;
+  const kh = d.kiosk_height    != null ? d.kiosk_height    : 80;
+  document.getElementById('kiosk-chat-enabled').checked  = !!d.kiosk_enabled;
+  document.getElementById('kiosk-chat-position').value   = d.kiosk_position || 'right';
+  document.getElementById('kiosk-chat-width').value      = kw; document.getElementById('kiosk-chat-width-val').textContent  = kw + '%';
+  document.getElementById('kiosk-chat-height').value     = kh; document.getElementById('kiosk-chat-height-val').textContent = kh + '%';
+  document.getElementById('kiosk-chat-status').textContent = d.kiosk_enabled ? '● Aktiv overlay' : '';
+  // Chat theme button (auto/dark/light)
+  try {
+    const KEY = 'kiosk_chat_theme';
+    const btn = document.getElementById('chat-theme-btn');
+    if (btn) {
+      const getTheme = () => localStorage.getItem(KEY) || 'auto';
+      const order = ['auto','dark','light'];
+      const updateBtn = () => {
+        const t = getTheme();
+        btn.textContent = t === 'auto' ? 'Chatt: Auto' : (t === 'dark' ? 'Chatt: Mörk' : 'Chatt: Ljus');
+      };
+      btn.addEventListener('click', () => {
+        const cur = getTheme();
+        const next = order[(order.indexOf(cur) + 1) % order.length];
+        localStorage.setItem(KEY, next);
+        updateBtn();
+        // notify display pages
+        window.dispatchEvent(new Event('kiosk-chat-theme-changed'));
+      });
+      // reflect existing value
+      updateBtn();
+      // react to external changes
+      window.addEventListener('storage', (e) => { if (e.key === KEY) updateBtn(); });
+    }
+  } catch (e) {}
+}
+
+async function _sendChat(ytEnabled, kioskEnabled) {
+  const res = await apiFetch('/api/chat', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      enabled:          ytEnabled,
+      position:         document.getElementById('chat-position').value,
+      width_pct:        parseInt(document.getElementById('chat-width-pct').value,   10),
+      height:           parseInt(document.getElementById('chat-height').value,      10),
+      bg_color:         document.getElementById('chat-bg-color').value,
+      bg_opacity:       parseInt(document.getElementById('chat-bg-opacity').value,  10),
+      kiosk_enabled:    kioskEnabled,
+      kiosk_position:   document.getElementById('kiosk-chat-position').value,
+      kiosk_width_pct:  parseInt(document.getElementById('kiosk-chat-width').value,  10),
+      kiosk_height:     parseInt(document.getElementById('kiosk-chat-height').value, 10),
+    }),
+  });
+  if (!res) return;
+  document.getElementById('chat-status').textContent       = ytEnabled    ? '● Aktiv' : '';
+  document.getElementById('kiosk-chat-status').textContent = kioskEnabled ? '● Aktiv overlay' : '';
+}
+
+function _currentKioskEnabled() { return document.getElementById('kiosk-chat-enabled').checked; }
+function _currentYtEnabled()    { return document.getElementById('chat-status').textContent.startsWith('●'); }
+
+function aktiveraChatOverlay(which) {
+  const yt    = which === 'yt'    ? true  : _currentYtEnabled();
+  const kiosk = which === 'kiosk' ? true  : _currentKioskEnabled();
+  document.getElementById('kiosk-chat-enabled').checked = kiosk;
+  _sendChat(yt, kiosk);
+}
+function avaktiveraChatOverlay(which) {
+  const yt    = which === 'yt'    ? false : _currentYtEnabled();
+  const kiosk = which === 'kiosk' ? false : _currentKioskEnabled();
+  document.getElementById('kiosk-chat-enabled').checked = kiosk;
+  _sendChat(yt, kiosk);
+}
+
+// --- Ticker ---
+
+function tickerModeChanged() {
+  const continuous = document.getElementById('ticker-mode').value === 'continuous';
+  document.getElementById('ticker-sep-label').style.display = continuous ? '' : 'none';
+  document.getElementById('ticker-sep-wrap').style.display  = continuous ? '' : 'none';
+}
+
+function tickerSourceChanged() {
+  const rss = document.getElementById('ticker-source').value === 'rss';
+  document.getElementById('ticker-text-label').style.display          = rss ? 'none' : '';
+  document.getElementById('ticker-text-wrap').style.display           = rss ? 'none' : '';
+  document.getElementById('ticker-rss-label').style.display           = rss ? '' : 'none';
+  document.getElementById('ticker-rss-wrap').style.display            = rss ? '' : 'none';
+  document.getElementById('ticker-rss-interval-label').style.display  = rss ? '' : 'none';
+  document.getElementById('ticker-rss-interval').style.display        = rss ? '' : 'none';
+}
+
+async function laddaTicker() {
+  const res = await apiFetch('/api/ticker');
+  if (!res) return;
+  const d = await res.json();
+
+  document.getElementById('ticker-source').value        = d.source || 'text';
+  document.getElementById('ticker-text').value          = d.text || '';
+  document.getElementById('ticker-rss-url').value       = d.rss_url || '';
+  document.getElementById('ticker-rss-interval').value  = String(d.rss_interval || 10);
+  document.getElementById('ticker-mode').value          = d.mode || 'sequential';
+  document.getElementById('ticker-separator').value     = d.separator || '◆';
+  document.getElementById('ticker-position').value      = d.position || 'bottom';
+  document.getElementById('ticker-text-color').value    = d.text_color || '#ffffff';
+  document.getElementById('ticker-font-family').value   = d.font_family || 'sans-serif';
+  document.getElementById('ticker-bar-color').value     = d.bar_color || '#000000';
+  tickerSourceChanged();
+  tickerModeChanged();
+
+  const pps     = d.pps     != null ? d.pps     : 150;
+  const size    = d.font_size   != null ? d.font_size   : 2;
+  const opacity = d.bar_opacity != null ? d.bar_opacity : 72;
+  const padding = d.bar_padding != null ? d.bar_padding : 0.45;
+
+  document.getElementById('ticker-pps').value           = pps;
+  document.getElementById('ticker-pps-val').textContent = pps + ' px/s';
+  document.getElementById('ticker-font-size').value     = size;
+  document.getElementById('ticker-font-size-val').textContent = parseFloat(size).toFixed(1) + 'rem';
+  document.getElementById('ticker-bar-opacity').value   = opacity;
+  document.getElementById('ticker-bar-opacity-val').textContent = opacity + '%';
+  document.getElementById('ticker-bar-padding').value   = padding;
+  document.getElementById('ticker-bar-padding-val').textContent = parseFloat(padding).toFixed(2) + 'rem';
+
+  document.getElementById('ticker-status').textContent = d.enabled ? '● Aktiv' : '';
+}
+
+async function _sendTicker(enabled) {
+  const res = await apiFetch('/api/ticker', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      enabled,
+      source:       document.getElementById('ticker-source').value,
+      text:         document.getElementById('ticker-text').value,
+      rss_url:      document.getElementById('ticker-rss-url').value,
+      rss_interval: parseInt(document.getElementById('ticker-rss-interval').value, 10),
+      mode:         document.getElementById('ticker-mode').value,
+      separator:    document.getElementById('ticker-separator').value,
+      position:     document.getElementById('ticker-position').value,
+      pps:          parseInt(document.getElementById('ticker-pps').value, 10),
+      text_color:   document.getElementById('ticker-text-color').value,
+      font_size:    parseFloat(document.getElementById('ticker-font-size').value),
+      font_family:  document.getElementById('ticker-font-family').value,
+      bar_color:    document.getElementById('ticker-bar-color').value,
+      bar_opacity:  parseInt(document.getElementById('ticker-bar-opacity').value, 10),
+      bar_padding:  parseFloat(document.getElementById('ticker-bar-padding').value),
+    }),
+  });
+  if (!res) return;
+  document.getElementById('ticker-status').textContent = enabled ? '● Aktiv' : '';
+}
+
+function aktiveraTicker()    { _sendTicker(true); }
+function avaktriveraTicker() { _sendTicker(false); }
+
+// BBCode insert helpers
+function insertBB(tag, openVal) {
+  const input = document.getElementById('ticker-text');
+  const start = input.selectionStart;
+  const end   = input.selectionEnd;
+  const sel   = input.value.slice(start, end);
+  const open  = openVal ? `[${tag}=${openVal}]` : `[${tag}]`;
+  const close = `[/${tag}]`;
+  input.value = input.value.slice(0, start) + open + sel + close + input.value.slice(end);
+  input.focus();
+  const cur = start + open.length;
+  input.setSelectionRange(cur, cur + sel.length);
+}
+
+function insertBBColor() {
+  const col = document.getElementById('bb-color-val').value;
+  insertBB('color', col);
+}
+
+function insertBBSize() {
+  const px = parseInt(document.getElementById('bb-size-val').value, 10) || 48;
+  insertBB('size', px);
+}
+
+function insertBBGlow() {
+  const col = document.getElementById('bb-color-val').value;
+  insertBB('glow', col);
+}
+
+// --- Wi-Fi ---
+
+let _wifiConnectSsid = '';
+let _wifiConnectOpen = false;
+
+function _signalColor(pct) {
+  if (pct >= 70) return '#22c55e';
+  if (pct >= 40) return '#f59e0b';
+  return '#ef4444';
+}
+
+function _signalBarsHtml(pct) {
+  const c = _signalColor(pct);
+  const lit = pct >= 70 ? 4 : pct >= 50 ? 3 : pct >= 30 ? 2 : 1;
+  const bars = [4, 7, 11, 14].map((h, i) =>
+    `<span style="width:4px;border-radius:1px;height:${h}px;background:${i < lit ? c : '#333'}"></span>`
+  ).join('');
+  return `<div style="display:flex;align-items:flex-end;gap:2px;height:14px">${bars}</div>`;
+}
+
+async function laddaWifiStatus() {
+  try {
+    const res = await apiFetch('/api/wifi/status');
+    if (!res) return;
+    const d = await res.json();
+    const dot  = document.getElementById('wifi-status-dot');
+    const txt  = document.getElementById('wifi-status-text');
+    const ip   = document.getElementById('wifi-ip');
+    const sig  = document.getElementById('wifi-signal-bar');
+    const sigv = document.getElementById('wifi-signal-val');
+    if (d.connected) {
+      dot.style.background = '#22c55e';
+      txt.textContent = d.ssid || 'Ansluten';
+    } else {
+      dot.style.background = '#ef4444';
+      txt.textContent = 'Ej ansluten';
+    }
+    ip.textContent = d.ip || '—';
+    const pct = d.connected ? Math.max(0, Math.min(100, 2 * (d.signal + 100))) : 0;
+    sig.innerHTML = _signalBarsHtml(pct);
+    sigv.textContent = d.connected ? pct + '%' : '—';
+    if (d.sudo_ok === false) {
+      document.getElementById('wifi-scan-status').textContent =
+        '⚠ sudo saknas — se installation nedan';
+    }
+  } catch {}
+}
+
+async function laddaWifiSaved() {
+  try {
+    const res = await apiFetch('/api/wifi/saved');
+    if (!res) return;
+    const d = await res.json();
+    const list = document.getElementById('wifi-saved-list');
+    if (!d.saved || !d.saved.length) {
+      list.innerHTML = '<span style="color:#555;font-style:italic">Inga sparade nätverk</span>';
+      return;
+    }
+    list.innerHTML = d.saved.map(n => `
+      <div style="display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;background:var(--surface);border-radius:6px">
+        <span style="flex:1">${escHtml(n.ssid)}${n.current ? ' <span style="color:#22c55e;font-size:.75rem">● aktiv</span>' : ''}</span>
+        <button class="secondary" style="padding:.2rem .6rem;font-size:.8rem" onclick="wifiRemove('${escHtml(n.id)}','${escHtml(n.ssid)}')">Ta bort</button>
+      </div>`).join('');
+  } catch {}
+}
+
+async function wifiScan() {
+  const btn    = document.getElementById('wifi-scan-btn');
+  const status = document.getElementById('wifi-scan-status');
+  const wrap   = document.getElementById('wifi-networks');
+  const list   = document.getElementById('wifi-network-list');
+  btn.disabled = true;
+  status.textContent = 'Skannar...';
+  list.innerHTML = '<span style="color:#555;font-style:italic">Söker...</span>';
+  wrap.style.display = 'block';
+  try {
+    const res = await apiFetch('/api/wifi/networks');
+    if (!res) { btn.disabled = false; status.textContent = ''; return; }
+    const d = await res.json();
+    if (!d.networks || !d.networks.length) {
+      list.innerHTML = '<span style="color:#555;font-style:italic">Inga nätverk hittades</span>';
+    } else {
+      list.innerHTML = d.networks.map(n => {
+        const lock = n.security !== 'open'
+          ? `<span style="font-size:.75rem;color:#aaa">${escHtml(n.security)}</span>` : '';
+        return `
+          <div style="display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;background:var(--surface);border-radius:6px">
+            ${_signalBarsHtml(n.signal)}
+            <span style="flex:1">${escHtml(n.ssid)}${n.known ? ' <span style="color:#22c55e;font-size:.75rem">✓</span>' : ''}</span>
+            ${lock}
+            <button class="secondary" style="padding:.2rem .6rem;font-size:.8rem"
+              onclick="wifiOpenModal('${escHtml(n.ssid)}',${n.security === 'open'})">
+              Anslut
+            </button>
+          </div>`;
+      }).join('');
+    }
+    status.textContent = d.networks.length + ' nätverk';
+  } catch (e) {
+    status.textContent = 'Fel: ' + e.message;
+    list.innerHTML = '';
+  }
+  btn.disabled = false;
+}
+
+function wifiOpenModal(ssid, isOpen) {
+  _wifiConnectSsid = ssid;
+  _wifiConnectOpen = isOpen;
+  document.getElementById('wifi-pw-title').textContent = 'Anslut till ' + ssid;
+  document.getElementById('wifi-pw-input').value = '';
+  document.getElementById('wifi-pw-input').type = isOpen ? 'text' : 'password';
+  document.getElementById('wifi-pw-input').placeholder = isOpen ? 'Öppet nätverk — inget lösenord behövs' : 'Wi-Fi lösenord';
+  document.getElementById('wifi-connect-status').textContent = '';
+  document.getElementById('wifi-connect-btn').disabled = false;
+  document.getElementById('wifi-pw-modal').style.display = 'flex';
+  if (!isOpen) document.getElementById('wifi-pw-input').focus();
+}
+
+function closeWifiModal() {
+  document.getElementById('wifi-pw-modal').style.display = 'none';
+}
+function wifiModalClick(e) {
+  if (e.target === document.getElementById('wifi-pw-modal')) closeWifiModal();
+}
+
+async function wifiDoConnect() {
+  const btn    = document.getElementById('wifi-connect-btn');
+  const status = document.getElementById('wifi-connect-status');
+  const pw     = document.getElementById('wifi-pw-input').value;
+  btn.disabled = true;
+  status.textContent = 'Ansluter... (upp till 20 s)';
+  try {
+    const res = await apiFetch('/api/wifi/connect', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ ssid: _wifiConnectSsid, password: pw }),
+    });
+    if (!res) { btn.disabled = false; return; }
+    const d = await res.json();
+    if (d.ok) {
+      status.textContent = '✓ Ansluten — IP: ' + (d.ip || '—');
+      setTimeout(() => { closeWifiModal(); laddaWifiStatus(); laddaWifiSaved(); }, 1500);
+    } else {
+      status.textContent = '✗ ' + (d.message || 'Misslyckades');
+      btn.disabled = false;
+    }
+  } catch (e) {
+    status.textContent = 'Fel: ' + e.message;
+    btn.disabled = false;
+  }
+}
+
+async function wifiDisconnect() {
+  const status = document.getElementById('wifi-scan-status');
+  status.textContent = 'Kopplar från...';
+  try {
+    await apiFetch('/api/wifi/disconnect', { method: 'POST' });
+    status.textContent = '';
+    setTimeout(laddaWifiStatus, 1000);
+  } catch {}
+}
+
+async function wifiRemove(id, ssid) {
+  if (!confirm(`Ta bort sparat nätverk "${ssid}"?`)) return;
+  try {
+    await apiFetch('/api/wifi/saved/' + encodeURIComponent(id), { method: 'DELETE' });
+    laddaWifiSaved();
+  } catch {}
+}
+
+// --- Klocka ---
+
+let _clockPos = 'top-right';
+
+function setClockPos(pos) {
+  _clockPos = pos;
+  document.querySelectorAll('.clock-pos-btn').forEach(b => b.classList.remove('active'));
+  const btn = document.getElementById('clkpos-' + pos);
+  if (btn) btn.classList.add('active');
+}
+
+async function laddaClock() {
+  const res = await apiFetch('/api/clock');
+  if (!res) return;
+  const d = await res.json();
+  document.getElementById('clock-style').value         = d.style         || 'digital';
+  document.getElementById('clock-show-date').checked   = !!d.show_date;
+  document.getElementById('clock-size').value          = d.size          != null ? d.size  : 2;
+  document.getElementById('clock-size-val').textContent = d.size         != null ? d.size  : 2;
+  document.getElementById('clock-color').value         = d.color         || '#ffffff';
+  document.getElementById('clock-opacity').value       = d.opacity       != null ? d.opacity : 90;
+  document.getElementById('clock-opacity-val').textContent = (d.opacity != null ? d.opacity : 90) + '%';
+  document.getElementById('clock-schedule-mode').value = d.schedule_mode || 'always';
+  setClockPos(d.position || 'top-right');
+  document.getElementById('clock-status').textContent  = d.enabled ? '● Aktiv' : '';
+}
+
+async function _sendClock(enabled) {
+  const res = await apiFetch('/api/clock', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      enabled,
+      style:         document.getElementById('clock-style').value,
+      show_date:     document.getElementById('clock-show-date').checked,
+      position:      _clockPos,
+      size:          parseFloat(document.getElementById('clock-size').value),
+      color:         document.getElementById('clock-color').value,
+      opacity:       parseInt(document.getElementById('clock-opacity').value, 10),
+      schedule_mode: document.getElementById('clock-schedule-mode').value,
+    }),
+  });
+  if (!res) return;
+  document.getElementById('clock-status').textContent = enabled ? '● Aktiv' : '';
+}
+
+function aktiveraClock()  { _sendClock(true);  }
+function avaktiveraClock() { _sendClock(false); }
+
+// --- Sovläge ---
+
+async function laddaSovlage() {
+  const res = await apiFetch('/api/sleep');
+  if (!res) return;
+  const d = await res.json();
+  document.getElementById('sleep-enabled').checked = !!d.enabled;
+  document.getElementById('sleep-start').value     = d.start || '22:00';
+  document.getElementById('sleep-end').value       = d.end   || '07:00';
+}
+
+async function sparaSovlage() {
+  const status = document.getElementById('sleep-status');
+  status.textContent = 'Sparar...';
+  const res = await apiFetch('/api/sleep', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      enabled: document.getElementById('sleep-enabled').checked,
+      start:   document.getElementById('sleep-start').value,
+      end:     document.getElementById('sleep-end').value,
+    }),
+  });
+  if (!res) return;
+  status.textContent = '✓ Sparat';
+  setTimeout(() => { status.textContent = ''; }, 3000);
+}
+
+// --- Systeminfo ---
+
+async function laddaSysinfo() {
+  const res = await apiFetch('/api/sysinfo');
+  if (!res) return;
+  const d = await res.json();
+  document.getElementById('cpu').textContent    = d.cpu + '%';
+  document.getElementById('ram').textContent    = d.ram + '%';
+  document.getElementById('disk').textContent   = d.disk + '%';
+  document.getElementById('uptime').textContent = d.uptime;
+  document.getElementById('active-source').textContent =
+    d.active_type !== '—' ? `[${d.active_type}] ${d.active_source}` : '—';
+}
+
+// --- Filbläddrare ---
+
+let _browserCallback = null;
+
+const FILE_TYPES = {
+  video: ['mp4','webm','avi','mkv','mov'],
+  image: ['jpg','jpeg','png','gif','webp','bmp'],
+  pdf:   ['pdf','ppt','pptx','odp'],
+  url:   ['html','htm'],
+};
+
+function extType(filename) {
+  const ext = filename.split('.').pop().toLowerCase();
+  for (const [type, exts] of Object.entries(FILE_TYPES)) {
+    if (exts.includes(ext)) return type;
+  }
+  return 'url';
+}
+
+function extClass(filename) {
+  const t = extType(filename);
+  return t === 'url' ? 'file-html' : 'file-' + t;
+}
+
+function openBrowser(callback) {
+  _browserCallback = callback;
+  document.getElementById('browser-modal').style.display = 'flex';
+  browseTo('/home/mrfox');
+}
+
+function closeBrowser() {
+  document.getElementById('browser-modal').style.display = 'none';
+  _browserCallback = null;
+}
+
+function browserOverlayClick(e) {
+  if (e.target === document.getElementById('browser-modal')) closeBrowser();
+}
+
+async function browseTo(path) {
+  const res = await apiFetch('/api/browse?path=' + encodeURIComponent(path));
+  if (!res) return;
+  const data = await res.json();
+  document.getElementById('browser-path').textContent = data.path;
+
+  let html = '';
+  if (data.parent) {
+    html += `<div class="browser-entry dir" data-path="${escHtml(data.parent)}">.. (upp)</div>`;
+  }
+  for (const e of data.entries) {
+    if (e.type === 'dir') {
+      html += `<div class="browser-entry dir" data-path="${escHtml(e.path)}">${escHtml(e.name)}</div>`;
+    } else {
+      html += `<div class="browser-entry ${escHtml(extClass(e.name))}" data-path="${escHtml(e.path)}">${escHtml(e.name)}</div>`;
+    }
+  }
+  if (!html) html = '<div style="padding:.75rem 1rem;color:#555;font-style:italic">Inga filer hittades</div>';
+
+  const list = document.getElementById('browser-list');
+  list.innerHTML = html;
+  list.querySelectorAll('.browser-entry.dir').forEach(el =>
+    el.addEventListener('click', () => browseTo(el.dataset.path))
+  );
+  list.querySelectorAll('.browser-entry:not(.dir)').forEach(el =>
+    el.addEventListener('click', () => {
+      if (_browserCallback) _browserCallback(el.dataset.path, extType(el.dataset.path));
+      closeBrowser();
+    })
+  );
+}
+
+// --- Helpers ---
+
+function fmtDuration(mins) {
+  const totalSec = Math.round(mins * 60);
+  const m = Math.floor(totalSec / 60);
+  const s = totalSec % 60;
+  return m > 0 ? `${m} min ${s > 0 ? s + ' sek' : ''}`.trim() : `${s} sek`;
+}
+
+function escHtml(str) {
+  return String(str)
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;');
+}
+
+// --- Init ---
+
+async function init() {
+  await laddaSysinfo();
+  await laddaJobb();
+  await laddaInstallningar();
+  await laddaVolym();
+  await laddaTicker();
+  await laddaChat();
+  await laddaClock();
+  await laddaWifiStatus();
+  await laddaWifiSaved();
+  await laddaSovlage();
+  setInterval(laddaSysinfo, 10000);
+
+  const res = await apiFetch('/api/sysinfo');
+  if (!res) return;
+  const d = await res.json();
+  if (d.active_type === 'url') {
+    document.getElementById('url-input').value = d.active_source;
+  }
+}
+
+init();
+
+// Theme toggle control for admin UI
+(function(){
+  const KEY = 'kiosk_theme';
+  function getTheme(){ return localStorage.getItem(KEY) || 'auto'; }
+  function applyTheme(pref){
+    if (pref === 'auto') {
+      delete document.documentElement.dataset.theme;
+    } else {
+      document.documentElement.dataset.theme = pref;
+    }
+  }
+  function cycleTheme(){
+    const order = ['auto','dark','light'];
+    const cur = getTheme();
+    const next = order[(order.indexOf(cur)+1) % order.length];
+    localStorage.setItem(KEY, next);
+    applyTheme(next);
+    updateBtn();
+    // notify other windows/tabs
+    window.dispatchEvent(new Event('kiosk-theme-changed'));
+  }
+  function updateBtn(){
+    const t = getTheme();
+    btn.textContent = t === 'auto' ? 'Tema: Auto' : (t === 'dark' ? 'Tema: Mörkt' : 'Tema: Ljust');
+  }
+  const container = document.querySelector('.container');
+  if (!container) return;
+  const btn = document.createElement('button');
+  btn.className = 'secondary';
+  btn.style.marginLeft = 'auto';
+  btn.style.marginBottom = '1rem';
+  btn.addEventListener('click', cycleTheme);
+  // Insert button under the H1
+  const h1 = container.querySelector('h1');
+  if (h1) h1.insertAdjacentElement('afterend', btn);
+  applyTheme(getTheme());
+  updateBtn();
+  // React to storage changes from other tabs
+  window.addEventListener('storage', (e) => {
+    if (e.key === KEY) { applyTheme(e.newValue || 'auto'); updateBtn(); }
+  });
+  // Also react to programmatic events
+  window.addEventListener('kiosk-theme-changed', () => { applyTheme(getTheme()); updateBtn(); });
+})();
diff --git a/kiosk/static/display.js b/kiosk/static/display.js
new file mode 100644
index 0000000..8569e02
--- /dev/null
+++ b/kiosk/static/display.js
@@ -0,0 +1,599 @@
+const socket = io({ reconnection: true, reconnectionDelay: 1000 });
+
+// Hide cursor — blank 1px GIF as custom cursor beats cursor:none in some Chromium builds
+(function(){
+  const BLANK = 'url("data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==") 0 0, none';
+  const s = document.createElement('style');
+  s.textContent = `*, *::before, *::after { cursor: ${BLANK} !important; }`;
+  document.head.appendChild(s);
+  // Belt-and-suspenders: reset on every mousemove in case something overrides it
+  document.addEventListener('mousemove', () => {
+    if (document.documentElement.style.cursor !== 'none')
+      document.documentElement.style.setProperty('cursor', 'none', 'important');
+  }, { passive: true });
+})();
+
+// Theme handling: read 'kiosk_theme' = 'auto'|'dark'|'light' from localStorage
+// Exposes window.kioskTheme.set/get for external toggles.
+(function(){
+  const KEY = 'kiosk_theme';
+  const mq = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
+  function apply(pref){
+    let dark;
+    if (pref === 'dark') dark = true;
+    else if (pref === 'light') dark = false;
+    else dark = !!(mq && mq.matches);
+    document.documentElement.classList.toggle('dark', dark);
+  }
+  const pref = localStorage.getItem(KEY) || 'auto';
+  apply(pref);
+  if (mq && mq.addEventListener) {
+    mq.addEventListener('change', () => {
+      // only react to system changes when in 'auto' mode
+      if ((localStorage.getItem(KEY) || 'auto') === 'auto') apply('auto');
+    });
+  }
+  window.kioskTheme = {
+    set: (p) => { localStorage.setItem(KEY, p); apply(p); window.dispatchEvent(new Event('kiosk-theme-changed')); },
+    get: () => localStorage.getItem(KEY) || 'auto'
+  };
+  // Sync theme when changed in other tabs
+  window.addEventListener('storage', (e) => {
+    if (e.key === KEY) apply(e.newValue || 'auto');
+  });
+})();
+
+function parseBBCode(raw) {
+  // HTML-escape first, then apply BBCode
+  let s = raw
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;');
+  s = s
+    .replace(/\[b\]([\s\S]*?)\[\/b\]/g,   '<strong>$1</strong>')
+    .replace(/\[i\]([\s\S]*?)\[\/i\]/g,   '<em>$1</em>')
+    .replace(/\[u\]([\s\S]*?)\[\/u\]/g,   '<u>$1</u>')
+    .replace(/\[s\]([\s\S]*?)\[\/s\]/g,   '<s>$1</s>')
+    .replace(/\[color=(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)\]([\s\S]*?)\[\/color\]/g,
+             '<span style="color:$1">$2</span>')
+    .replace(/\[size=([1-9][0-9]{0,2})\]([\s\S]*?)\[\/size\]/g,
+             '<span style="font-size:$1px">$2</span>')
+    .replace(/\[font=([a-zA-Z0-9 ,'\-]+)\]([\s\S]*?)\[\/font\]/g,
+             '<span style="font-family:$1">$2</span>')
+    .replace(/\[shadow\]([\s\S]*?)\[\/shadow\]/g,
+             '<span style="text-shadow:2px 2px 6px #000,0 0 12px #000">$1</span>')
+    .replace(/\[glow=(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)\]([\s\S]*?)\[\/glow\]/g,
+             '<span style="text-shadow:0 0 8px $1,0 0 18px $1,0 0 30px $1">$2</span>');
+  return s;
+}
+
+function hexToRgba(hex, opacity) {
+  const r = parseInt(hex.slice(1, 3), 16);
+  const g = parseInt(hex.slice(3, 5), 16);
+  const b = parseInt(hex.slice(5, 7), 16);
+  return `rgba(${r},${g},${b},${opacity / 100})`;
+}
+
+let _ticker = { lines: [], idx: 0, data: null, rssTimer: null, rafId: null };
+
+// Duration in seconds for constant pixel-speed
+function _calcDur(spanEl, pps) {
+  return (window.innerWidth + spanEl.offsetWidth) / (pps || 150);
+}
+
+// Escape for use as literal separator (not BBCode)
+function _escSep(s) {
+  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+}
+
+async function _loadRssLines() {
+  try {
+    const r = await fetch('/api/ticker/rss');
+    if (!r.ok) return;
+    const d = await r.json();
+    if (d.items && d.items.length) {
+      const changed = JSON.stringify(d.items) !== JSON.stringify(_ticker.lines);
+      _ticker.lines = d.items;
+      // Restart continuous ticker when content changes
+      if (changed && _ticker.data && _ticker.data.mode === 'continuous') {
+        _ticker.idx = 0;
+        _tickerShowLine();
+      }
+    }
+  } catch {}
+}
+
+async function applyTicker(data) {
+  const el   = document.getElementById('ticker');
+  const span = document.getElementById('ticker-text');
+
+  span.removeEventListener('animationend', _tickerNextLine);
+  if (_ticker.rssTimer) { clearInterval(_ticker.rssTimer); _ticker.rssTimer = null; }
+  if (_ticker.rafId)    { cancelAnimationFrame(_ticker.rafId); _ticker.rafId = null; }
+
+  const hasContent = data.source === 'rss' || (data.text && data.text.trim());
+  if (!data.enabled || !hasContent) {
+    el.style.display = 'none';
+    return;
+  }
+
+  _ticker.idx  = 0;
+  _ticker.data = data;
+
+  if (data.source === 'rss') {
+    _ticker.lines = [];          // rensa innan hämtning så gamla rader inte läcker
+    await _loadRssLines();
+    if (!_ticker.lines.length) { el.style.display = 'none'; return; }
+    const ms = (data.rss_interval || 10) * 60 * 1000;
+    _ticker.rssTimer = setInterval(_loadRssLines, ms);
+  } else {
+    _ticker.lines = data.text.split(/\[br\]|\n/).map(l => l.trim()).filter(l => l);
+    if (!_ticker.lines.length) { el.style.display = 'none'; return; }
+  }
+
+  // Bar style
+  el.className        = data.position === 'top' ? 'top' : 'bottom';
+  el.style.background = hexToRgba(data.bar_color || '#000000', (data.bar_opacity != null ? data.bar_opacity : 72));
+  el.style.padding    = `${(data.bar_padding != null ? data.bar_padding : 0.45)}rem 0`;
+  el.style.display    = 'block';
+
+  // Text style (global defaults, overridden per-span by BBCode)
+  span.style.color      = data.text_color  || '#ffffff';
+  span.style.fontSize   = (data.font_size  || 2) + 'rem';
+  span.style.fontFamily = data.font_family || 'sans-serif';
+
+  _tickerShowLine();
+  // Re-position overlays so they don't overlap the ticker bar
+  requestAnimationFrame(() => {
+    _updateChatOverlay();
+    _updateKioskChatOverlay();
+    const clockEl = document.getElementById('clock-overlay');
+    if (clockEl && clockEl.style.display !== 'none') _positionClock(clockEl);
+  });
+}
+
+function _tickerShowLine() {
+  const span         = document.getElementById('ticker-text');
+  const { lines, idx, data } = _ticker;
+  const pps          = data.pps || 150;
+  const continuous   = data.mode === 'continuous';
+
+  const sep = `<span style="opacity:.5;padding:0 .5em">${_escSep(data.separator || '◆')}</span>`;
+
+  if (continuous) {
+    // Build one copy of the band, measure it, then duplicate for seamless looping
+    const band = lines.map(l => parseBBCode(l)).join(sep);
+    span.style.animation = 'none';
+    span.style.transform = '';
+    span.innerHTML = band + sep;
+    span.offsetHeight; // reflow to measure one copy
+    const copyW = span.offsetWidth;
+    // Duplicate so the loop point is seamless
+    span.innerHTML = band + sep + band + sep;
+
+    if (_ticker.rafId) { cancelAnimationFrame(_ticker.rafId); _ticker.rafId = null; }
+    let x = window.innerWidth; // start off-screen right
+    let last = null;
+    function tickContinuous(ts) {
+      if (!last) last = ts;
+      x -= pps * (ts - last) / 1000;
+      // When first copy has fully exited left, reset by one copy width (seamless)
+      if (x <= -copyW) x += copyW;
+      span.style.transform = `translateX(${x}px)`;
+      last = ts;
+      _ticker.rafId = requestAnimationFrame(tickContinuous);
+    }
+    _ticker.rafId = requestAnimationFrame(tickContinuous);
+  } else {
+    span.style.animation = 'none';
+    span.style.transform = '';
+    span.innerHTML = parseBBCode(lines[idx]);
+    span.offsetHeight;
+
+    if (lines.length > 1) {
+      const dur = _calcDur(span, pps).toFixed(2);
+      span.style.animation = `ticker-scroll ${dur}s linear 1`;
+      span.addEventListener('animationend', _tickerNextLine, { once: true });
+    } else {
+      const dur = _calcDur(span, pps).toFixed(2);
+      span.style.animation = `ticker-scroll ${dur}s linear infinite`;
+    }
+  }
+}
+
+function _tickerNextLine() {
+  _ticker.idx = (_ticker.idx + 1) % _ticker.lines.length;
+  _tickerShowLine();
+}
+
+// --- Chat overlays (YouTube + Kiosk livechat) ---
+
+// jobEnabled: true = follow global _chat.enabled, false = force-hide chat for this job
+let _chat        = { enabled: false, position: 'right', width_pct: 25, height: 100,
+                     bg_color: '#000000', bg_opacity: 70, jobEnabled: true,
+                     kiosk_enabled: false, kiosk_width_pct: 30, kiosk_height: 80,
+                     kiosk_position: 'right' };
+let _currentYtId  = null;
+let _currentType  = null;
+
+function applyChat(data) {
+  _chat.enabled         = !!data.enabled;
+  _chat.position        = data.position        || 'right';
+  _chat.width_pct       = data.width_pct       != null ? data.width_pct       : 25;
+  _chat.height          = data.height          != null ? data.height          : 100;
+  _chat.bg_color        = data.bg_color        || '#000000';
+  _chat.bg_opacity      = data.bg_opacity      != null ? data.bg_opacity      : 70;
+  _chat.kiosk_enabled   = !!data.kiosk_enabled;
+  _chat.kiosk_width_pct = data.kiosk_width_pct != null ? data.kiosk_width_pct : 30;
+  _chat.kiosk_height    = data.kiosk_height    != null ? data.kiosk_height    : 80;
+  _chat.kiosk_position  = data.kiosk_position  || 'right';
+  _updateChatOverlay();
+  _updateKioskChatOverlay();
+}
+
+// YouTube chat overlay
+function _updateChatOverlay() {
+  const overlay = document.getElementById('chat-overlay');
+  const frame   = document.getElementById('chat-frame');
+
+  if (!_chat.enabled || !_chat.jobEnabled || !_currentYtId) {
+    overlay.style.display = 'none';
+    return;
+  }
+
+  const src = `https://www.youtube.com/live_chat?is_popout=1&v=${_currentYtId}&embed_domain=${window.location.hostname}`;
+  try {
+    const CHAT_KEY = 'kiosk_chat_theme';
+    const chatPref = localStorage.getItem(CHAT_KEY) || 'auto';
+    let wantDark;
+    if (chatPref === 'dark') wantDark = true;
+    else if (chatPref === 'light') wantDark = false;
+    else {
+      const mq = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
+      wantDark = !!(mq && mq.matches);
+    }
+    const finalSrc = src + (wantDark ? '&dark_theme=1&theme=dark' : '');
+    if (frame.src !== finalSrc) frame.src = finalSrc;
+  } catch (e) {
+    if (frame.src !== src) frame.src = src;
+  }
+
+  _positionOverlay(overlay, _chat.position, _chat.width_pct, _chat.height);
+  overlay.style.background = hexToRgba(_chat.bg_color, _chat.bg_opacity);
+  overlay.style.display    = 'block';
+}
+
+// Kiosk livechat overlay — inbyggd, ansluter direkt till /chat-namespacet
+const _kcSocket = io('/chat');
+const _kcMsgs   = [];
+const KC_MAX    = 30;
+
+_kcSocket.on('chat_history', (msgs) => {
+  const container = document.getElementById('kc-msgs');
+  if (container) container.innerHTML = '';
+  _kcMsgs.length = 0;
+  msgs.forEach(m => _kcAddMsg(m.name, m.text, m.color, m.emoji, m.avatar_url));
+});
+_kcSocket.on('chat_message', (d) => {
+  _kcAddMsg(d.name, d.text, d.color, d.emoji, d.avatar_url);
+});
+_kcSocket.on('system_message', (d) => {
+  _kcAddSystem(d.text);
+});
+_kcSocket.on('viewer_count', (d) => {
+  const el = document.getElementById('kc-count');
+  if (el) el.textContent = d.count + ' aktiva';
+});
+
+function _kcAvatarHtml(emoji, name, avatarUrl) {
+  if (avatarUrl) return `<img src="${avatarUrl}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
+  return _kcEsc(emoji || _kcInitial(name));
+}
+
+function _kcAddMsg(name, text, color, emoji, avatarUrl) {
+  const el = document.createElement('div');
+  el.className = 'kc-msg';
+  el.innerHTML = `
+    <div class="kc-avatar" style="background:${color}22;color:${color}">${_kcAvatarHtml(emoji, name, avatarUrl)}</div>
+    <div class="kc-body">
+      <div class="kc-name" style="color:${color}">${_kcEsc(name)}</div>
+      <div class="kc-text">${_kcEsc(text)}</div>
+    </div>`;
+  _kcAppend(el);
+}
+
+function _kcAddSystem(text) {
+  const el = document.createElement('div');
+  el.className = 'kc-system';
+  el.textContent = text;
+  _kcAppend(el);
+}
+
+function _kcAppend(el) {
+  const container = document.getElementById('kc-msgs');
+  if (!container) return;
+  container.appendChild(el);
+  _kcMsgs.push(el);
+  if (_kcMsgs.length > KC_MAX) {
+    const old = _kcMsgs.shift();
+    old.remove();
+  }
+}
+
+function _kcInitial(name) { return name.charAt(0).toUpperCase(); }
+function _kcEsc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
+
+function _updateKioskChatOverlay() {
+  const overlay = document.getElementById('kiosk-chat-overlay');
+  // Visa inte om: inaktiverad, job-döljer, är kiosk_chat-vy, eller YT-chat tar samma plats
+  const ytChatActive = _chat.enabled && _chat.jobEnabled && !!_currentYtId;
+  const showAsOverlay = _chat.kiosk_enabled && _chat.jobEnabled
+                        && _currentType !== 'kiosk_chat'
+                        && !ytChatActive;
+
+  if (!showAsOverlay) {
+    overlay.style.display = 'none';
+    return;
+  }
+
+  _positionOverlay(overlay, _chat.kiosk_position, _chat.kiosk_width_pct, _chat.kiosk_height);
+  overlay.style.display = 'flex';
+}
+
+function _positionOverlay(el, position, widthPct, heightPct) {
+  const isRight = position !== 'left';
+  el.style.right = isRight ? '0' : 'auto';
+  el.style.left  = isRight ? 'auto' : '0';
+  el.style.width = widthPct + '%';
+
+  // Offset for ticker bar if it is visible
+  const ticker   = document.getElementById('ticker');
+  const tickerH  = (ticker && ticker.offsetHeight > 0) ? ticker.offsetHeight : 0;
+  const topOff   = (ticker && ticker.classList.contains('top'))    ? tickerH : 0;
+  const bottomOff = (ticker && ticker.classList.contains('bottom')) ? tickerH : 0;
+
+  const availH = window.innerHeight - topOff - bottomOff;
+  const wantH  = Math.round(availH * heightPct / 100);
+  const topPx  = Math.round(topOff + (availH - wantH) / 2);
+
+  el.style.height  = wantH + 'px';
+  el.style.top     = topPx + 'px';
+  el.style.opacity = '1';
+}
+
+socket.on('chat_settings', (data) => {
+  applyChat(data);
+});
+
+// --- Clock overlay ---
+
+let _clock = { enabled: false, style: 'digital', show_date: false, position: 'top-right',
+               size: 2, color: '#ffffff', opacity: 90, schedule_mode: 'always',
+               _timer: null, _raf: null };
+let _isScheduled = false;
+
+function applyClockSettings(data) {
+  _clock.enabled       = !!data.enabled;
+  _clock.style         = data.style         || 'digital';
+  _clock.show_date     = !!data.show_date;
+  _clock.position      = data.position      || 'top-right';
+  _clock.size          = data.size          != null ? data.size  : 2;
+  _clock.color         = data.color         || '#ffffff';
+  _clock.opacity       = data.opacity       != null ? data.opacity : 90;
+  _clock.schedule_mode = data.schedule_mode || 'always';
+  _refreshClock();
+}
+
+function _refreshClock() {
+  if (_clock._timer) { clearInterval(_clock._timer);        _clock._timer = null; }
+  if (_clock._raf)   { cancelAnimationFrame(_clock._raf);   _clock._raf   = null; }
+
+  const el      = document.getElementById('clock-overlay');
+  const digital = document.getElementById('clock-digital');
+  const canvas  = document.getElementById('clock-analog');
+  const dateEl  = document.getElementById('clock-date');
+
+  const shouldShow = _clock.enabled &&
+    (_clock.schedule_mode !== 'scheduled_only' || _isScheduled);
+
+  if (!shouldShow) { el.style.display = 'none'; return; }
+
+  const sz  = _clock.size;
+  const em  = (sz * 2 + 2);   // 4rem–12rem
+  const px  = Math.round(sz * 60 + 60);  // 120px–360px
+
+  digital.style.display    = (_clock.style === 'digital') ? 'block' : 'none';
+  canvas.style.display     = (_clock.style === 'analog')  ? 'block' : 'none';
+  digital.style.fontSize   = em + 'rem';
+  digital.style.fontFamily = 'monospace';
+  digital.style.color      = _clock.color;
+  digital.style.textShadow = '0 2px 12px rgba(0,0,0,0.85)';
+  digital.style.lineHeight = '1';
+
+  canvas.width  = px;
+  canvas.height = px;
+
+  const dateSz = Math.max(1.2, em * 0.28);
+  dateEl.style.display    = _clock.show_date ? 'block' : 'none';
+  dateEl.style.fontSize   = dateSz + 'rem';
+  dateEl.style.color      = _clock.color;
+  dateEl.style.fontFamily = 'monospace';
+  dateEl.style.textShadow = '0 2px 8px rgba(0,0,0,0.85)';
+  dateEl.style.marginTop  = '0.4rem';
+  dateEl.style.textAlign  = 'center';
+
+  el.style.opacity = (_clock.opacity / 100).toString();
+  el.style.display = 'flex';
+  _positionClock(el);
+  _renderClock();
+
+  if (_clock.style === 'analog') {
+    (function loop() { _renderClock(); _clock._raf = requestAnimationFrame(loop); })();
+  } else {
+    _clock._timer = setInterval(_renderClock, 1000);
+  }
+}
+
+function _renderClock() {
+  const now = new Date();
+  if (_clock.style === 'digital') {
+    const el = document.getElementById('clock-digital');
+    if (el) el.textContent =
+      String(now.getHours()).padStart(2,'0') + ':' +
+      String(now.getMinutes()).padStart(2,'0') + ':' +
+      String(now.getSeconds()).padStart(2,'0');
+  } else {
+    _drawAnalogClock(now);
+  }
+  if (_clock.show_date) {
+    const days   = ['Sön','Mån','Tis','Ons','Tor','Fre','Lör'];
+    const months = ['jan','feb','mar','apr','maj','jun','jul','aug','sep','okt','nov','dec'];
+    const el = document.getElementById('clock-date');
+    if (el) el.textContent = days[now.getDay()] + ' ' + now.getDate() + ' ' + months[now.getMonth()];
+  }
+}
+
+function _drawAnalogClock(now) {
+  const canvas = document.getElementById('clock-analog');
+  if (!canvas) return;
+  const ctx = canvas.getContext('2d');
+  const W = canvas.width, H = canvas.height;
+  const cx = W / 2, cy = H / 2;
+  const R  = W / 2 - 2;
+  const c  = _clock.color;
+
+  ctx.clearRect(0, 0, W, H);
+
+  // Face
+  ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2);
+  ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fill();
+  ctx.strokeStyle = c; ctx.lineWidth = Math.max(2, R * 0.03); ctx.stroke();
+
+  // Ticks
+  for (let i = 0; i < 12; i++) {
+    const a = (i / 12) * Math.PI * 2 - Math.PI / 2;
+    const outer = R * 0.92;
+    const inner = (i % 3 === 0) ? R * 0.76 : R * 0.84;
+    ctx.beginPath();
+    ctx.moveTo(cx + Math.cos(a) * outer, cy + Math.sin(a) * outer);
+    ctx.lineTo(cx + Math.cos(a) * inner, cy + Math.sin(a) * inner);
+    ctx.strokeStyle = c;
+    ctx.lineWidth = (i % 3 === 0) ? Math.max(2, R * 0.04) : Math.max(1, R * 0.02);
+    ctx.stroke();
+  }
+
+  const h  = now.getHours() % 12;
+  const m  = now.getMinutes();
+  const s  = now.getSeconds();
+  const ms = now.getMilliseconds();
+  const hA = ((h + m / 60)          / 12) * Math.PI * 2 - Math.PI / 2;
+  const mA = ((m + s / 60)          / 60) * Math.PI * 2 - Math.PI / 2;
+  const sA = ((s + ms / 1000)       / 60) * Math.PI * 2 - Math.PI / 2;
+
+  _hand(ctx, cx, cy, hA, R * 0.55, Math.max(3, R * 0.055), c);
+  _hand(ctx, cx, cy, mA, R * 0.78, Math.max(2, R * 0.035), c);
+  _hand(ctx, cx, cy, sA, R * 0.88, Math.max(1, R * 0.018), '#ef4444');
+  _hand(ctx, cx, cy, sA + Math.PI, R * 0.18, Math.max(1, R * 0.018), '#ef4444');
+
+  ctx.beginPath(); ctx.arc(cx, cy, Math.max(3, R * 0.045), 0, Math.PI * 2);
+  ctx.fillStyle = c; ctx.fill();
+  ctx.beginPath(); ctx.arc(cx, cy, Math.max(2, R * 0.025), 0, Math.PI * 2);
+  ctx.fillStyle = '#ef4444'; ctx.fill();
+}
+
+function _hand(ctx, cx, cy, angle, len, width, color) {
+  ctx.beginPath();
+  ctx.moveTo(cx, cy);
+  ctx.lineTo(cx + Math.cos(angle) * len, cy + Math.sin(angle) * len);
+  ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.stroke();
+}
+
+function _positionClock(el) {
+  const pos     = _clock.position;
+  const pad     = 18;
+  const ticker  = document.getElementById('ticker');
+  const tickerH = (ticker && ticker.offsetHeight > 0) ? ticker.offsetHeight : 0;
+  const topOff  = (ticker && ticker.classList.contains('top'))    ? tickerH : 0;
+  const botOff  = (ticker && ticker.classList.contains('bottom')) ? tickerH : 0;
+
+  el.style.top = el.style.bottom = el.style.left = el.style.right = el.style.transform = '';
+  if      (pos === 'top-left')     { el.style.top    = (pad + topOff) + 'px'; el.style.left  = pad + 'px'; }
+  else if (pos === 'top-right')    { el.style.top    = (pad + topOff) + 'px'; el.style.right = pad + 'px'; }
+  else if (pos === 'bottom-left')  { el.style.bottom = (pad + botOff) + 'px'; el.style.left  = pad + 'px'; }
+  else if (pos === 'bottom-right') { el.style.bottom = (pad + botOff) + 'px'; el.style.right = pad + 'px'; }
+  else { el.style.top = '50%'; el.style.left = '50%'; el.style.transform = 'translate(-50%,-50%)'; }
+}
+
+socket.on('clock_settings', applyClockSettings);
+
+socket.on('connect', () => {
+  fetch('/api/ticker').then(r => r.json()).then(applyTicker).catch(() => {});
+  fetch('/api/chat').then(r => r.json()).then(applyChat).catch(() => {});
+  fetch('/api/clock').then(r => r.json()).then(applyClockSettings).catch(() => {});
+});
+
+function showLayer(name) {
+  ['iframe', 'video', 'local', 'image', 'idle'].forEach(l => {
+    document.getElementById('layer-' + l).style.display = 'none';
+  });
+  document.getElementById('layer-' + name).style.display = 'block';
+}
+
+socket.on('switch', (data) => {
+  // Per-job chat override: false = force-hide, anything else = follow global setting
+  _chat.jobEnabled = (data.show_chat !== false);
+
+  // Track scheduled flag for clock schedule_mode
+  const prevScheduled = _isScheduled;
+  _isScheduled = (data.scheduled === true);
+  if (_isScheduled !== prevScheduled && _clock.schedule_mode === 'scheduled_only') {
+    _refreshClock();
+  }
+
+  // Track YouTube video ID and current type for overlays
+  const ytMatch = (data.source || '').match(/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/);
+  _currentYtId  = ytMatch ? ytMatch[1] : null;
+  _currentType  = data.type;
+
+  if (data.type === 'kiosk_chat') {
+    document.getElementById('frame').src = window.location.origin + '/chat-display';
+    showLayer('iframe');
+
+  } else if (data.type === 'url') {
+    document.getElementById('frame').src = data.source;
+    showLayer('iframe');
+
+  } else if (data.type === 'video') {
+    const v = document.getElementById('video');
+    v.src = data.source;
+    showLayer('video');
+    v.load();
+    v.play().catch(() => { v.muted = true; v.play(); });
+
+  } else if (data.type === 'image') {
+    document.getElementById('image').src = data.source;
+    showLayer('image');
+
+  } else if (data.type === 'pdf' || data.type === 'game') {
+    document.getElementById('frame').src = data.source;
+    showLayer('iframe');
+
+  } else if (data.type === 'local') {
+    const html = data.html || '';
+    if (!html && !data.source) {
+      showLayer('idle');
+    } else {
+      document.getElementById('layer-local').innerHTML = html;
+      showLayer('local');
+    }
+  }
+
+  _updateChatOverlay();
+  _updateKioskChatOverlay();
+});
+
+socket.on('ticker', applyTicker);
+
+socket.on('sleep_toggle', (data) => {
+  document.getElementById('layer-sleep').style.display = data.active ? 'block' : 'none';
+});
diff --git a/kiosk/static/qrcode.min.js b/kiosk/static/qrcode.min.js
new file mode 100644
index 0000000..993e88f
--- /dev/null
+++ b/kiosk/static/qrcode.min.js
@@ -0,0 +1 @@
+var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c<a.length&&0==a[c];)c++;this.num=new Array(a.length-c+b);for(var d=0;d<a.length-c;d++)this.num[d]=a[d+c]}function j(a,b){this.totalCount=a,this.dataCount=b}function k(){this.buffer=[],this.length=0}function m(){return"undefined"!=typeof CanvasRenderingContext2D}function n(){var a=!1,b=navigator.userAgent;return/android/i.test(b)&&(a=!0,aMat=b.toString().match(/android ([0-9]\.[0-9])/i),aMat&&aMat[1]&&(a=parseFloat(aMat[1]))),a}function r(a,b){for(var c=1,e=s(a),f=0,g=l.length;g>=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d<this.moduleCount;d++){this.modules[d]=new Array(this.moduleCount);for(var e=0;e<this.moduleCount;e++)this.modules[d][e]=null}this.setupPositionProbePattern(0,0),this.setupPositionProbePattern(this.moduleCount-7,0),this.setupPositionProbePattern(0,this.moduleCount-7),this.setupPositionAdjustPattern(),this.setupTimingPattern(),this.setupTypeInfo(a,c),this.typeNumber>=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f<this.modules.length;f++)for(var g=f*e,h=0;h<this.modules[f].length;h++){var i=h*e,j=this.modules[f][h];j&&(d.beginFill(0,100),d.moveTo(i,g),d.lineTo(i+e,g),d.lineTo(i+e,g+e),d.lineTo(i,g+e),d.endFill())}return d},setupTimingPattern:function(){for(var a=8;a<this.moduleCount-8;a++)null==this.modules[a][6]&&(this.modules[a][6]=0==a%2);for(var b=8;b<this.moduleCount-8;b++)null==this.modules[6][b]&&(this.modules[6][b]=0==b%2)},setupPositionAdjustPattern:function(){for(var a=f.getPatternPosition(this.typeNumber),b=0;b<a.length;b++)for(var c=0;c<a.length;c++){var d=a[b],e=a[c];if(null==this.modules[d][e])for(var g=-2;2>=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g<a.length&&(j=1==(1&a[g]>>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h<d.length;h++){var i=d[h];g.put(i.mode,4),g.put(i.getLength(),f.getLengthInBits(i.mode,a)),i.write(g)}for(var l=0,h=0;h<e.length;h++)l+=e[h].dataCount;if(g.getLengthInBits()>8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j<b.length;j++){var k=b[j].dataCount,l=b[j].totalCount-k;d=Math.max(d,k),e=Math.max(e,l),g[j]=new Array(k);for(var m=0;m<g[j].length;m++)g[j][m]=255&a.buffer[m+c];c+=k;var n=f.getErrorCorrectPolynomial(l),o=new i(g[j],n.getLength()-1),p=o.mod(n);h[j]=new Array(n.getLength()-1);for(var m=0;m<h[j].length;m++){var q=m+p.getLength()-h[j].length;h[j][m]=q>=0?p.get(q):0}}for(var r=0,m=0;m<b.length;m++)r+=b[m].totalCount;for(var s=new Array(r),t=0,m=0;d>m;m++)for(var j=0;j<b.length;j++)m<g[j].length&&(s[t++]=g[j][m]);for(var m=0;e>m;m++)for(var j=0;j<b.length;j++)m<h[j].length&&(s[t++]=h[j][m]);return s};for(var c={MODE_NUMBER:1,MODE_ALPHA_NUM:2,MODE_8BIT_BYTE:4,MODE_KANJI:8},d={L:1,M:0,Q:3,H:2},e={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7},f={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:1335,G18:7973,G15_MASK:21522,getBCHTypeInfo:function(a){for(var b=a<<10;f.getBCHDigit(b)-f.getBCHDigit(f.G15)>=0;)b^=f.G15<<f.getBCHDigit(b)-f.getBCHDigit(f.G15);return(a<<10|b)^f.G15_MASK},getBCHTypeNumber:function(a){for(var b=a<<12;f.getBCHDigit(b)-f.getBCHDigit(f.G18)>=0;)b^=f.G18<<f.getBCHDigit(b)-f.getBCHDigit(f.G18);return a<<12|b},getBCHDigit:function(a){for(var b=0;0!=a;)b++,a>>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<<h;for(var h=8;256>h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;c<this.getLength();c++)for(var d=0;d<a.getLength();d++)b[c+d]^=g.gexp(g.glog(this.get(c))+g.glog(a.get(d)));return new i(b,0)},mod:function(a){if(this.getLength()-a.getLength()<0)return this;for(var b=g.glog(this.get(0))-g.glog(a.get(0)),c=new Array(this.getLength()),d=0;d<this.getLength();d++)c[d]=this.get(d);for(var d=0;d<a.getLength();d++)c[d]^=g.gexp(g.glog(a.get(d))+b);return new i(c,0).mod(a)}},j.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],j.getRSBlocks=function(a,b){var c=j.getRsBlockTable(a,b);if(void 0==c)throw new Error("bad rs block @ typeNumber:"+a+"/errorCorrectLevel:"+b);for(var d=c.length/3,e=[],f=0;d>f;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=['<table style="border:0;border-collapse:collapse;">'],h=0;d>h;h++){g.push("<tr>");for(var i=0;d>i;i++)g.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:'+e+"px;height:"+f+"px;background-color:"+(a.isDark(h,i)?b.colorDark:b.colorLight)+';"></td>');g.push("</tr>")}g.push("</table>"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}();
\ No newline at end of file
diff --git a/kiosk/static/style.css b/kiosk/static/style.css
new file mode 100644
index 0000000..5b3c0b0
--- /dev/null
+++ b/kiosk/static/style.css
@@ -0,0 +1,359 @@
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+/* Theme variables — supports prefers-color-scheme */
+:root {
+  --bg: #ffffff;
+  --text: #111111;
+  --muted: #777777;
+  --surface: #f6f6f6;
+  --border: #dddddd;
+  --accent: #2563eb;
+  --accent-hover: #1d4ed8;
+  --panel-bg: #ffffff;
+  --panel-border: #e6e6e6;
+  --control-bg: #ffffff;
+  --control-border: #cccccc;
+  --control-text: var(--text);
+  --progress-fill: #2563eb;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root:not([data-theme]) {
+    --bg: #111111;
+    --text: #e0e0e0;
+    --muted: #aaaaaa;
+    --surface: #1a1a1a;
+    --border: #2a2a2a;
+    --accent: #2563eb;
+    --accent-hover: #1d4ed8;
+    --panel-bg: #1a1a1a;
+    --panel-border: #2a2a2a;
+    --control-bg: #222222;
+    --control-border: #333333;
+    --control-text: var(--text);
+    --progress-fill: #2563eb;
+  }
+}
+
+/* Manuell temaöverstyring */
+:root[data-theme="dark"] {
+  --bg: #111111;
+  --text: #e0e0e0;
+  --muted: #aaaaaa;
+  --surface: #1a1a1a;
+  --border: #2a2a2a;
+  --accent: #2563eb;
+  --accent-hover: #1d4ed8;
+  --panel-bg: #1a1a1a;
+  --panel-border: #2a2a2a;
+  --control-bg: #222222;
+  --control-border: #333333;
+  --control-text: var(--text);
+  --progress-fill: #2563eb;
+}
+
+:root[data-theme="light"] {
+  --bg: #ffffff;
+  --text: #111111;
+  --muted: #777777;
+  --surface: #f6f6f6;
+  --border: #dddddd;
+  --accent: #2563eb;
+  --accent-hover: #1d4ed8;
+  --panel-bg: #ffffff;
+  --panel-border: #e6e6e6;
+  --control-bg: #ffffff;
+  --control-border: #cccccc;
+  --control-text: var(--text);
+  --progress-fill: #2563eb;
+}
+
+body {
+  font-family: system-ui, sans-serif;
+  background: var(--bg);
+  color: var(--text);
+  line-height: 1.5;
+}
+
+.container {
+  max-width: 960px;
+  margin: 0 auto;
+  padding: 2rem 1rem;
+}
+
+h1 {
+  font-size: 1.6rem;
+  margin-bottom: 2rem;
+  color: var(--text);
+}
+
+h2 {
+  font-size: 1.1rem;
+  color: var(--muted);
+  text-transform: uppercase;
+  letter-spacing: .08em;
+  margin-bottom: 1rem;
+}
+
+h3 {
+  font-size: .95rem;
+  color: var(--muted);
+  margin-bottom: .75rem;
+}
+
+section {
+  background: var(--panel-bg);
+  border: 1px solid var(--panel-border);
+  border-radius: 8px;
+  padding: 1.25rem 1.5rem;
+  margin-bottom: 1.5rem;
+}
+
+/* Inputs & buttons */
+input, select {
+  background: var(--control-bg);
+  border: 1px solid var(--control-border);
+  color: var(--control-text);
+  padding: .45rem .7rem;
+  border-radius: 5px;
+  font-size: .9rem;
+}
+
+input:focus, select:focus {
+  outline: 2px solid #4a9eff;
+  border-color: transparent;
+}
+
+button {
+  background: var(--accent);
+  color: #fff;
+  border: none;
+  padding: .45rem .9rem;
+  border-radius: 5px;
+  cursor: pointer;
+  font-size: .9rem;
+  white-space: nowrap;
+}
+
+button:hover { background: var(--accent-hover); }
+button.secondary { background: var(--control-bg); color: var(--control-text); border: 1px solid var(--control-border); }
+button.secondary:hover { filter: brightness(.95); }
+button.danger { background: #991b1b; }
+button.danger:hover { background: #7f1d1d; }
+button.toggle.on { background: #166534; }
+button.toggle.on:hover { background: #14532d; }
+button.toggle.off { background: var(--control-bg); color: var(--control-text); border: 1px solid var(--control-border); }
+button.toggle.off:hover { filter: brightness(.95); }
+
+/* Rows */
+.row {
+  display: flex;
+  gap: .5rem;
+  margin-bottom: .6rem;
+  align-items: center;
+}
+
+.row input { flex: 1; }
+
+/* Job form */
+.form-grid {
+  display: flex;
+  gap: .5rem;
+  flex-wrap: wrap;
+  align-items: center;
+}
+
+.form-grid input, .form-grid select { flex: 1; min-width: 120px; }
+
+/* Table */
+table {
+  width: 100%;
+  border-collapse: collapse;
+  margin-bottom: 1.25rem;
+  font-size: .875rem;
+}
+
+th {
+  text-align: left;
+  color: #777;
+  font-weight: 500;
+  padding: .4rem .5rem;
+  border-bottom: 1px solid #2a2a2a;
+}
+
+td {
+  padding: .5rem .5rem;
+  border-bottom: 1px solid #222;
+  vertical-align: middle;
+}
+
+.source-cell {
+  max-width: 240px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.empty { color: #555; font-style: italic; }
+
+/* Sysinfo */
+.sysinfo-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: .75rem;
+}
+
+.stat {
+  background: var(--control-bg);
+  border-radius: 6px;
+  padding: .6rem .9rem;
+  display: flex;
+  flex-direction: column;
+  min-width: 100px;
+}
+
+.stat.wide { flex: 1; min-width: 260px; }
+.stat .label { font-size: .7rem; color: #666; text-transform: uppercase; letter-spacing: .06em; }
+.stat span:last-child { font-size: 1.1rem; font-weight: 600; color: #fff; margin-top: .15rem; }
+
+/* File browser modal */
+.modal-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0,0,0,.7);
+  z-index: 100;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.modal {
+  background: var(--panel-bg);
+  border: 1px solid var(--panel-border);
+  border-radius: 8px;
+  width: min(560px, 95vw);
+  max-height: 70vh;
+  display: flex;
+  flex-direction: column;
+}
+
+.modal-header {
+  display: flex;
+  align-items: center;
+  gap: .5rem;
+  padding: .75rem 1rem;
+  border-bottom: 1px solid var(--panel-border);
+  flex-shrink: 0;
+}
+
+.modal-header .modal-path {
+  flex: 1;
+  font-size: .8rem;
+  color: #888;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.modal-shortcuts {
+  display: flex;
+  gap: .4rem;
+  padding: .5rem 1rem;
+  border-bottom: 1px solid #222;
+  flex-shrink: 0;
+  flex-wrap: wrap;
+}
+
+.browser-list {
+  overflow-y: auto;
+  flex: 1;
+  padding: .25rem 0;
+}
+
+.browser-entry {
+  padding: .45rem 1rem;
+  cursor: pointer;
+  font-size: .875rem;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.browser-entry:hover { background: var(--control-bg); }
+.browser-entry.dir::before { content: '📁 '; }
+.browser-entry.file-video::before { content: '🎬 '; }
+.browser-entry.file-image::before { content: '🖼️ '; }
+.browser-entry.file-html::before { content: '📄 '; }
+
+.source-with-browse { display: flex; gap: .4rem; flex: 1; min-width: 180px; }
+.source-with-browse input { flex: 1; min-width: 0; }
+
+/* BBCode toolbar */
+.bbcode-bar {
+  display: flex;
+  flex-wrap: wrap;
+  gap: .3rem;
+  margin-top: .4rem;
+  align-items: center;
+}
+.bbcode-bar .bb-btn { padding: .3rem .5rem; font-size: .8rem; }
+.bb-sep { width: 1px; height: 1.5rem; background: #333; margin: 0 .1rem; flex-shrink: 0; }
+.bbcode-bar input[type=number] { width: 4rem; flex: none; min-width: 0; }
+
+/* 2-column label/control grid */
+.prop-grid {
+  display: grid;
+  grid-template-columns: auto 1fr;
+  gap: .5rem .85rem;
+  align-items: center;
+  margin-bottom: .6rem;
+}
+.prop-grid label { color: #aaa; font-size: .9rem; white-space: nowrap; }
+.prop-grid .row { margin-bottom: 0; }
+input[type=color] {
+  width: 2.5rem; height: 2rem;
+  padding: .1rem .15rem;
+  cursor: pointer;
+  background: var(--control-bg);
+  border: 1px solid var(--control-border);
+  border-radius: 5px;
+  flex: none;
+}
+
+/* Upload */
+.upload-label {
+  display: inline-flex;
+  align-items: center;
+  background: var(--accent);
+  color: #fff;
+  padding: .45rem .9rem;
+  border-radius: 5px;
+  cursor: pointer;
+  font-size: .9rem;
+  white-space: nowrap;
+}
+.upload-label:hover { background: var(--accent-hover); }
+.upload-label input[type=file] { display: none; }
+
+#upload-progress {
+  display: flex;
+  align-items: center;
+  gap: .75rem;
+  margin-bottom: .6rem;
+}
+.progress-bar {
+  flex: 1;
+  background: var(--control-bg);
+  border-radius: 4px;
+  height: 6px;
+  overflow: hidden;
+}
+#progress-fill {
+  height: 100%;
+  background: var(--progress-fill);
+  border-radius: 4px;
+  width: 0%;
+  transition: width .15s;
+}
+#upload-status { font-size: .8rem; color: #888; white-space: nowrap; }
+.clock-pos-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
diff --git a/kiosk/sway-apps.sh b/kiosk/sway-apps.sh
new file mode 100755
index 0000000..9442c2e
--- /dev/null
+++ b/kiosk/sway-apps.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# sway-apps.sh — startar chromium på rätt workspace när sway är redo.
+# Körs via "exec" i sway.conf.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CONF="$SCRIPT_DIR/kiosk.conf"
+
+# Vänta tills WiFi-adapter har en IP-adress (max 40 s)
+LOCAL_IP=""
+for _i in $(seq 1 40); do
+  LOCAL_IP=$(ip route get 8.8.8.8 2>/dev/null \
+    | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}')
+  [ -n "$LOCAL_IP" ] && [ "$LOCAL_IP" != "127.0.0.1" ] && break
+  sleep 1
+done
+[ -z "$LOCAL_IP" ] && LOCAL_IP="localhost"
+
+KIOSK_URL="http://${LOCAL_IP}:5000/display"
+ADMIN_URL="http://${LOCAL_IP}:5000/admin"
+KIOSK_INTERNAL_DISPLAY="on"
+
+if [ -f "$CONF" ]; then
+  # shellcheck disable=SC1090
+  . "$CONF"
+fi
+
+CHROMIUM_FLAGS="--kiosk --ozone-platform=wayland --noerr --no-first-run \
+  --disable-infobars --disable-session-crashed-bubble \
+  --disable-features=Translate --disable-web-security \
+  --autoplay-policy=no-user-gesture-required --no-sandbox"
+
+# Vänta tills kiosk-servern svarar
+until curl -sf -o /dev/null "$KIOSK_URL" 2>/dev/null; do sleep 1; done
+
+# --- Pre-seed Chromium-profil: stäng av translate ---
+mkdir -p /tmp/kiosk-display/Default
+python3 - <<'PYEOF'
+import json, os
+f = '/tmp/kiosk-display/Default/Preferences'
+p = json.load(open(f)) if os.path.exists(f) else {}
+p.setdefault('translate', {})['enabled'] = False
+p.setdefault('translate_blocked_languages', ['sv','en'])
+json.dump(p, open(f, 'w'))
+PYEOF
+
+# --- Workspace 1 (HDMI-A-1): kiosk-display ---
+swaymsg workspace 1
+sleep 0.3
+swaymsg exec "chromium $CHROMIUM_FLAGS \
+  --user-data-dir=/tmp/kiosk-display \
+  $KIOSK_URL"
+
+# --- Workspace 2 (eDP-1): admin ---
+if [ "$KIOSK_INTERNAL_DISPLAY" = "on" ]; then
+  sleep 1
+  swaymsg workspace 2
+  sleep 0.3
+  swaymsg exec "chromium $CHROMIUM_FLAGS \
+    --user-data-dir=/tmp/kiosk-admin \
+    $ADMIN_URL"
+fi
+
+# Återvänd fokus till kiosk-display
+sleep 1
+swaymsg workspace 1
diff --git a/kiosk/sway.conf b/kiosk/sway.conf
new file mode 100644
index 0000000..d75a1bc
--- /dev/null
+++ b/kiosk/sway.conf
@@ -0,0 +1,37 @@
+# sway.conf — kiosk-konfiguration
+# Output-direktion och workspace-tilldelning genereras dynamiskt av kiosk-launcher.sh
+include /tmp/kiosk-sway-outputs.conf
+
+# ── Utseende ──────────────────────────────────────────────────────────────────
+default_border none
+hide_edge_borders both
+gaps inner 0
+gaps outer 0
+font pango:Sans 1
+
+# ── Inaktivera statusbar ───────────────────────────────────────────────────────
+bar {
+  mode hide
+}
+
+# ── Alla chromium-fönster: fullskärm, håll skärmen vaken ─────────────────────
+for_window [app_id="chromium"] {
+  fullscreen enable
+  inhibit_idle fullscreen
+}
+
+# ── Dölj muspekare efter 3 s inaktivitet ─────────────────────────────────────
+seat seat0 hide_cursor 3000
+
+# ── Inaktivera skärmsläckning ─────────────────────────────────────────────────
+exec swayidle -w \
+  timeout 0 '' \
+  before-sleep '' \
+  2>/dev/null || true
+
+# ── Starta applikationer ──────────────────────────────────────────────────────
+exec /home/mrfox/codes/display/kiosk/sway-apps.sh
+exec /home/mrfox/codes/display/kiosk/hdmi-monitor.sh
+
+# ── Nödutgång (om tangentbord finns) ─────────────────────────────────────────
+bindsym --to-code Mod4+Shift+e exec swaymsg exit
diff --git a/kiosk/templates/admin.html b/kiosk/templates/admin.html
new file mode 100644
index 0000000..067f7fa
--- /dev/null
+++ b/kiosk/templates/admin.html
@@ -0,0 +1,587 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Kiosk Admin</title>
+  <link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+  <div class="container">
+    <h1>Kiosk Admin</h1>
+
+    <!-- Sektion 1: Visa nu -->
+    <section>
+      <h2>Visa nu</h2>
+
+      <div class="row">
+        <input id="url-input" type="url" placeholder="https://example.com eller YouTube-länk">
+        <button onclick="visaAdress()">Visa</button>
+      </div>
+
+      <div class="row">
+        <label class="upload-label">
+          <input id="upload-input" type="file" accept="video/*,image/*,.html,.htm,.pdf,.pptx,.ppt,.odp" onchange="laddaUpp()">
+          Ladda upp fil
+        </label>
+        <button class="secondary" onclick="openBrowser(visaLokalFil)">Välj lokal fil</button>
+      </div>
+      <div id="upload-progress" style="display:none">
+        <div class="progress-bar"><div id="progress-fill"></div></div>
+        <span id="upload-status"></span>
+      </div>
+      <div class="row">
+        <button class="secondary" onclick="tomSkarm()">Töm skärm</button>
+        <button class="secondary" onclick="laddaOmDisplay()">Ladda om display</button>
+      </div>
+    </section>
+
+    <!-- Sektion 2: Schema -->
+    <section>
+      <h2>Schema</h2>
+
+      <table id="jobs-table">
+        <thead>
+          <tr>
+            <th>Namn</th>
+            <th>Tid</th>
+            <th>Typ</th>
+            <th>Källa</th>
+            <th>Chat</th>
+          <th>Aktiv</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody id="jobs-body">
+          <tr><td colspan="7" class="empty">Laddar...</td></tr>
+        </tbody>
+      </table>
+
+      <form id="job-form" onsubmit="laggTillJobb(event)">
+        <h3>Lägg till schemalagt jobb</h3>
+        <div class="form-grid">
+          <input name="label" type="text" placeholder="Namn" required>
+          <input name="duration" type="number" min="0.01" step="any" placeholder="Minuter (ex. 0.5)" required>
+          <select name="type">
+            <option value="url">URL / HTML</option>
+            <option value="youtube">YouTube</option>
+            <option value="video">Video</option>
+            <option value="image">Bild</option>
+            <option value="pdf">PDF / Presentation</option>
+            <option value="kiosk_chat">Kiosk Livechat</option>
+          </select>
+          <div class="source-with-browse">
+            <input name="source" type="text" placeholder="URL eller välj fil" required>
+            <button type="button" class="secondary" onclick="openBrowser(setSchemaCalla)">Bläddra</button>
+          </div>
+          <label style="display:flex;align-items:center;gap:.5rem;color:#aaa;font-size:.9rem">
+            <input type="checkbox" name="show_chat"> Visa livechat (YouTube)
+          </label>
+          <button type="submit">Lägg till</button>
+        </div>
+      </form>
+
+      <!-- Edit modal (re-uses job form fields) -->
+      <div id="edit-modal" class="modal-overlay" style="display:none" onclick="editModalClick(event)">
+        <div class="modal">
+          <div class="modal-header">
+            <strong>Redigera jobb</strong>
+            <button class="secondary" onclick="closeEditModal()">✕</button>
+          </div>
+          <div style="padding:1rem">
+            <form id="edit-form" onsubmit="saveEdit(event)">
+              <input name="id" type="hidden">
+              <div class="form-grid">
+                <input name="label" type="text" placeholder="Namn" required>
+                <input name="duration" type="number" min="0.01" step="any" placeholder="Minuter" required>
+                <select name="type">
+                  <option value="url">URL / HTML</option>
+                  <option value="youtube">YouTube</option>
+                  <option value="video">Video</option>
+                  <option value="image">Bild</option>
+                  <option value="pdf">PDF / Presentation</option>
+                  <option value="kiosk_chat">Kiosk Livechat</option>
+                </select>
+                <input name="source" type="text" placeholder="URL eller filväg" required>
+              </div>
+              <div style="margin-top:.6rem"><button type="submit">Spara</button></div>
+            </form>
+          </div>
+        </div>
+      </div>
+    </section>
+
+    <!-- Sektion 3: Ticker -->
+    <section>
+      <h2>Löptext</h2>
+
+      <div class="prop-grid">
+        <label>Källa</label>
+        <select id="ticker-source" onchange="tickerSourceChanged()">
+          <option value="text">Text</option>
+          <option value="rss">RSS-flöde</option>
+        </select>
+
+        <label id="ticker-text-label">Text</label>
+        <div id="ticker-text-wrap">
+          <textarea id="ticker-text" rows="4" placeholder="Välkommen! Öppet 10–18... 🎉&#10;Rad 2 scrollas efter rad 1&#10;Rad 3..." style="width:100%;resize:vertical;font-family:inherit;line-height:1.5"></textarea>
+          <div class="bbcode-bar">
+            <button type="button" class="secondary bb-btn" onclick="insertBB('b')" title="Fet"><b>B</b></button>
+            <button type="button" class="secondary bb-btn" onclick="insertBB('i')" title="Kursiv"><i>I</i></button>
+            <button type="button" class="secondary bb-btn" onclick="insertBB('u')" title="Understruken"><u>U</u></button>
+            <button type="button" class="secondary bb-btn" onclick="insertBB('s')" title="Genomstruken"><s>S</s></button>
+            <span class="bb-sep"></span>
+            <input type="color" id="bb-color-val" value="#ffff00" title="Välj färg">
+            <button type="button" class="secondary bb-btn" onclick="insertBBColor()" title="Infoga färgtagg">[color]</button>
+            <input type="number" id="bb-size-val" value="48" min="8" max="300">
+            <button type="button" class="secondary bb-btn" onclick="insertBBSize()" title="Infoga storlekstagg">[size]</button>
+            <span class="bb-sep"></span>
+            <button type="button" class="secondary bb-btn" onclick="insertBB('shadow')" title="Textskugga">Skugga</button>
+            <button type="button" class="secondary bb-btn" onclick="insertBBGlow()" title="Glödeffekt (använder vald färg)">Glöd</button>
+          </div>
+          <div style="font-size:.75rem;color:#444;margin-top:.35rem">
+            Varje rad scrollas en i taget. Radbryt med Enter eller [br]. BBCode: [b]fet[/b] &nbsp;[color=#ff0000]röd[/color] &nbsp;[size=64]stor[/size] &nbsp;[shadow]skugga[/shadow]
+          </div>
+        </div>
+
+        <label id="ticker-rss-label" style="display:none">RSS-URL</label>
+        <div id="ticker-rss-wrap" style="display:none">
+          <input id="ticker-rss-url" type="url" placeholder="https://feeds.example.com/rss" style="width:100%">
+          <div style="font-size:.75rem;color:#444;margin-top:.35rem">
+            Stöder RSS 2.0 och Atom. Rubrikerna hämtas och scrollas en i taget.
+          </div>
+        </div>
+
+        <label id="ticker-rss-interval-label" style="display:none">Uppdatera</label>
+        <select id="ticker-rss-interval" style="display:none">
+          <option value="1">Varje minut</option>
+          <option value="5">Var 5:e minut</option>
+          <option value="10" selected>Var 10:e minut</option>
+          <option value="30">Var 30:e minut</option>
+          <option value="60">Varje timme</option>
+        </select>
+
+        <label>Position</label>
+        <select id="ticker-position">
+          <option value="bottom">Nederkant</option>
+          <option value="top">Överkant</option>
+        </select>
+
+        <label>Hastighet</label>
+        <div class="row">
+          <span style="color:#555;font-size:.8rem;white-space:nowrap">Långsam</span>
+          <input type="range" id="ticker-pps" min="30" max="800" step="10" value="150"
+                 oninput="document.getElementById('ticker-pps-val').textContent=this.value+' px/s'">
+          <span style="color:#555;font-size:.8rem;white-space:nowrap">Snabb</span>
+          <span id="ticker-pps-val" style="color:#aaa;min-width:5rem;text-align:right">150 px/s</span>
+        </div>
+
+        <label>Scrolläge</label>
+        <select id="ticker-mode" onchange="tickerModeChanged()">
+          <option value="sequential">En rad i taget</option>
+          <option value="continuous">Löpande (kontinuerligt)</option>
+        </select>
+
+        <label id="ticker-sep-label" style="display:none">Separator</label>
+        <div id="ticker-sep-wrap" style="display:none" class="row">
+          <input id="ticker-separator" type="text" value="◆" style="max-width:8rem">
+          <span style="color:#555;font-size:.8rem">visas mellan rader, t.ex. ◆ • | 🔸</span>
+        </div>
+      </div>
+
+      <h3>Textstil</h3>
+      <div class="prop-grid">
+        <label>Färg</label>
+        <div class="row">
+          <input type="color" id="ticker-text-color" value="#ffffff">
+          <span style="color:#aaa;font-size:.85rem">Standard (överskrivs av [color]-taggar)</span>
+        </div>
+
+        <label>Storlek</label>
+        <div class="row">
+          <input type="range" id="ticker-font-size" min="0.8" max="5" step="0.1" value="2"
+                 oninput="document.getElementById('ticker-font-size-val').textContent=parseFloat(this.value).toFixed(1)+'rem'">
+          <span id="ticker-font-size-val" style="color:#aaa;min-width:4rem">2.0rem</span>
+        </div>
+
+        <label>Typsnitt</label>
+        <select id="ticker-font-family">
+          <optgroup label="System">
+            <option value="sans-serif">System sans-serif</option>
+            <option value="serif">System serif</option>
+            <option value="monospace">Monospace</option>
+          </optgroup>
+          <optgroup label="Webb">
+            <option value="Arial, sans-serif">Arial</option>
+            <option value="Georgia, serif">Georgia</option>
+            <option value="Impact, sans-serif">Impact</option>
+            <option value="'Courier New', monospace">Courier New</option>
+            <option value="'Times New Roman', serif">Times New Roman</option>
+          </optgroup>
+          <optgroup label="Kod (apt: fonts-cascadia-code)">
+            <option value="'Cascadia Code', monospace">Cascadia Code</option>
+          </optgroup>
+          <optgroup label="Nerd Fonts (installeras separat)">
+            <option value="'JetBrainsMono Nerd Font', monospace">JetBrainsMono NF</option>
+            <option value="'FiraCode Nerd Font', monospace">FiraCode NF</option>
+            <option value="'Hack Nerd Font', monospace">Hack NF</option>
+            <option value="'Inconsolata Nerd Font', monospace">Inconsolata NF</option>
+            <option value="'CaskaydiaCove Nerd Font', monospace">CaskaydiaCove NF</option>
+            <option value="'RobotoMono Nerd Font', monospace">RobotoMono NF</option>
+            <option value="'MesloLGS NF', monospace">MesloLGS NF</option>
+            <option value="'DejaVuSansMono Nerd Font', monospace">DejaVuSansMono NF</option>
+          </optgroup>
+        </select>
+      </div>
+
+      <h3>Tickerrad</h3>
+      <div class="prop-grid">
+        <label>Bakgrundsfärg</label>
+        <input type="color" id="ticker-bar-color" value="#000000">
+
+        <label>Opacitet</label>
+        <div class="row">
+          <input type="range" id="ticker-bar-opacity" min="0" max="100" step="5" value="72"
+                 oninput="document.getElementById('ticker-bar-opacity-val').textContent=this.value+'%'">
+          <span id="ticker-bar-opacity-val" style="color:#aaa;min-width:3rem">72%</span>
+        </div>
+
+        <label>Höjd</label>
+        <div class="row">
+          <input type="range" id="ticker-bar-padding" min="0.1" max="1.5" step="0.05" value="0.45"
+                 oninput="document.getElementById('ticker-bar-padding-val').textContent=parseFloat(this.value).toFixed(2)+'rem'">
+          <span id="ticker-bar-padding-val" style="color:#aaa;min-width:4rem">0.45rem</span>
+        </div>
+      </div>
+
+      <div class="row">
+        <button onclick="aktiveraTicker()">Aktivera</button>
+        <button class="secondary" onclick="avaktriveraTicker()">Stäng av</button>
+        <span id="ticker-status" style="color:#aaa;font-size:0.85rem;align-self:center"></span>
+      </div>
+    </section>
+
+    <!-- Sektion 4: Chat -->
+    <section>
+      <h2>Livechat</h2>
+
+      <h3>Kiosk Livechat</h3>
+      <p style="font-size:.85rem;color:#555;margin-bottom:.75rem">Inbyggd chat — besökare deltar via QR-kod på sin mobil. Kan visas som helskärm (schema-typ "Kiosk Livechat") eller som overlay ovanpå annat innehåll.</p>
+
+      <div class="prop-grid">
+        <label>Overlay</label>
+        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+          <input type="checkbox" id="kiosk-chat-enabled">
+          Visa kiosk-chat som overlay ovanpå annat innehåll
+        </label>
+
+        <label>Position</label>
+        <select id="kiosk-chat-position">
+          <option value="right">Höger sida</option>
+          <option value="left">Vänster sida</option>
+        </select>
+
+        <label>Bredd</label>
+        <div class="row">
+          <input type="range" id="kiosk-chat-width" min="10" max="60" step="1" value="30"
+                 oninput="document.getElementById('kiosk-chat-width-val').textContent=this.value+'%'">
+          <span id="kiosk-chat-width-val" style="color:#aaa;min-width:3rem">30%</span>
+        </div>
+
+        <label>Höjd</label>
+        <div class="row">
+          <input type="range" id="kiosk-chat-height" min="30" max="100" step="5" value="80"
+                 oninput="document.getElementById('kiosk-chat-height-val').textContent=this.value+'%'">
+          <span id="kiosk-chat-height-val" style="color:#aaa;min-width:3rem">80%</span>
+        </div>
+      </div>
+
+      <div class="row" style="margin-bottom:1.25rem">
+        <button onclick="aktiveraChatOverlay('kiosk')">Aktivera overlay</button>
+        <button class="secondary" onclick="avaktiveraChatOverlay('kiosk')">Stäng av overlay</button>
+        <span id="kiosk-chat-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+
+      <h3>YouTube Livechat</h3>
+      <p style="font-size:.85rem;color:#555;margin-bottom:1rem">Visas automatiskt som en overlay när ett YouTube-liveflöde spelas. Döljs vid annat innehåll.</p>
+
+      <div class="prop-grid">
+        <label>Position</label>
+        <select id="chat-position">
+          <option value="right">Höger sida</option>
+          <option value="left">Vänster sida</option>
+        </select>
+
+        <label>Bredd</label>
+        <div class="row">
+          <input type="range" id="chat-width-pct" min="5" max="60" step="1" value="25"
+                 oninput="document.getElementById('chat-width-pct-val').textContent=this.value+'%'">
+          <span id="chat-width-pct-val" style="color:#aaa;min-width:3rem">25%</span>
+        </div>
+
+        <label>Höjd</label>
+        <div class="row">
+          <input type="range" id="chat-height" min="30" max="100" step="5" value="100"
+                 oninput="document.getElementById('chat-height-val').textContent=this.value+'%'">
+          <span id="chat-height-val" style="color:#aaa;min-width:3rem">100%</span>
+        </div>
+      </div>
+
+      <h3>Bakgrund</h3>
+      <div class="prop-grid">
+        <label>Färg</label>
+        <input type="color" id="chat-bg-color" value="#000000">
+
+        <label>Transparens</label>
+        <div class="row">
+          <input type="range" id="chat-bg-opacity" min="0" max="100" step="5" value="70"
+                 oninput="document.getElementById('chat-bg-opacity-val').textContent=this.value+'%'">
+          <span id="chat-bg-opacity-val" style="color:#aaa;min-width:3rem">70%</span>
+        </div>
+      </div>
+
+      <div class="row">
+        <button onclick="aktiveraChatOverlay('yt')">Aktivera</button>
+        <button class="secondary" onclick="avaktiveraChatOverlay('yt')">Stäng av</button>
+        <button id="chat-theme-btn" class="secondary">Chatt: Auto</button>
+        <span id="chat-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+    </section>
+
+    <!-- Sektion 5: Klocka -->
+    <section>
+      <h2>Klocka</h2>
+
+      <div class="prop-grid">
+        <label>Stil</label>
+        <select id="clock-style">
+          <option value="digital">Digital</option>
+          <option value="analog">Analog</option>
+        </select>
+
+        <label>Datum</label>
+        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+          <input type="checkbox" id="clock-show-date">
+          Visa datum under klockan
+        </label>
+
+        <label>Position</label>
+        <div style="display:flex;gap:.4rem;flex-wrap:wrap">
+          <button type="button" id="clkpos-top-left"     class="secondary clock-pos-btn" onclick="setClockPos('top-left')">↖ Övre vänster</button>
+          <button type="button" id="clkpos-top-right"    class="secondary clock-pos-btn" onclick="setClockPos('top-right')">↗ Övre höger</button>
+          <button type="button" id="clkpos-bottom-left"  class="secondary clock-pos-btn" onclick="setClockPos('bottom-left')">↙ Nedre vänster</button>
+          <button type="button" id="clkpos-bottom-right" class="secondary clock-pos-btn" onclick="setClockPos('bottom-right')">↘ Nedre höger</button>
+          <button type="button" id="clkpos-center"       class="secondary clock-pos-btn" onclick="setClockPos('center')">⊙ Centrerad</button>
+        </div>
+
+        <label>Storlek</label>
+        <div class="row">
+          <span style="color:#555;font-size:.8rem;white-space:nowrap">Liten</span>
+          <input type="range" id="clock-size" min="1" max="5" step="0.5" value="2"
+                 oninput="document.getElementById('clock-size-val').textContent=this.value">
+          <span style="color:#555;font-size:.8rem;white-space:nowrap">Stor</span>
+          <span id="clock-size-val" style="color:#aaa;min-width:2rem;text-align:right">2</span>
+        </div>
+
+        <label>Färg</label>
+        <input type="color" id="clock-color" value="#ffffff">
+
+        <label>Opacitet</label>
+        <div class="row">
+          <input type="range" id="clock-opacity" min="10" max="100" step="5" value="90"
+                 oninput="document.getElementById('clock-opacity-val').textContent=this.value+'%'">
+          <span id="clock-opacity-val" style="color:#aaa;min-width:3rem">90%</span>
+        </div>
+
+        <label>Schema</label>
+        <select id="clock-schedule-mode">
+          <option value="always">Visa alltid</option>
+          <option value="scheduled_only">Bara under schemalagd visning</option>
+        </select>
+      </div>
+
+      <div class="row" style="margin-top:.75rem">
+        <button onclick="aktiveraClock()">Aktivera</button>
+        <button class="secondary" onclick="avaktiveraClock()">Stäng av</button>
+        <span id="clock-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+    </section>
+
+    <!-- Sektion 6: Sovläge -->
+    <section>
+      <h2>Sovläge</h2>
+      <div class="prop-grid">
+        <label>Aktivera</label>
+        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+          <input type="checkbox" id="sleep-enabled">
+          Täck displayen med svart skärm under angiven tid
+        </label>
+
+        <label>Sov från</label>
+        <input type="time" id="sleep-start" value="22:00" style="max-width:9rem">
+
+        <label>Vakna kl</label>
+        <input type="time" id="sleep-end" value="07:00" style="max-width:9rem">
+      </div>
+      <div class="row" style="margin-top:.75rem">
+        <button onclick="sparaSovlage()">Spara</button>
+        <span id="sleep-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+    </section>
+
+    <!-- Sektion: Wi-Fi -->
+    <section>
+      <h2>Wi-Fi</h2>
+
+      <!-- Status -->
+      <div id="wifi-status-box" class="prop-grid" style="margin-bottom:1rem">
+        <label>Status</label>
+        <div>
+          <span id="wifi-status-dot" style="display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:#555;margin-right:.4rem;vertical-align:middle"></span>
+          <span id="wifi-status-text" style="font-size:.9rem">Laddar...</span>
+        </div>
+        <label>IP</label>
+        <span id="wifi-ip" style="font-size:.9rem;font-family:monospace">—</span>
+        <label>Signal</label>
+        <div style="display:flex;align-items:center;gap:.5rem">
+          <div id="wifi-signal-bar" style="display:flex;align-items:flex-end;gap:2px;height:14px">
+            <span style="width:4px;border-radius:1px;background:#333;height:4px"></span>
+            <span style="width:4px;border-radius:1px;background:#333;height:7px"></span>
+            <span style="width:4px;border-radius:1px;background:#333;height:11px"></span>
+            <span style="width:4px;border-radius:1px;background:#333;height:14px"></span>
+          </div>
+          <span id="wifi-signal-val" style="color:#aaa;font-size:.8rem">—</span>
+        </div>
+      </div>
+
+      <div class="row" style="margin-bottom:1rem">
+        <button onclick="wifiScan()" id="wifi-scan-btn">Skanna nätverk</button>
+        <button class="secondary" onclick="wifiDisconnect()">Koppla från</button>
+        <span id="wifi-scan-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+
+      <!-- Nätverkslista -->
+      <div id="wifi-networks" style="display:none;margin-bottom:1.25rem">
+        <h3>Tillgängliga nätverk</h3>
+        <div id="wifi-network-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem"></div>
+      </div>
+
+      <!-- Sparade nätverk -->
+      <div id="wifi-saved-wrap">
+        <h3>Sparade nätverk</h3>
+        <div id="wifi-saved-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem;font-size:.9rem"></div>
+      </div>
+    </section>
+
+    <!-- Wi-Fi lösenordsmodal -->
+    <div id="wifi-pw-modal" class="modal-overlay" style="display:none" onclick="wifiModalClick(event)">
+      <div class="modal">
+        <div class="modal-header">
+          <strong id="wifi-pw-title">Anslut till nätverk</strong>
+          <button class="secondary" onclick="closeWifiModal()">✕</button>
+        </div>
+        <div style="padding:1rem">
+          <div class="prop-grid">
+            <label>Lösenord</label>
+            <input id="wifi-pw-input" type="password" placeholder="Lämna tomt om öppet nätverk" style="width:100%" onkeydown="if(event.key==='Enter')wifiDoConnect()">
+          </div>
+          <div class="row" style="margin-top:.75rem">
+            <button onclick="wifiDoConnect()" id="wifi-connect-btn">Anslut</button>
+            <span id="wifi-connect-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- Sektion 6: Inställningar -->
+    <section>
+      <h2>Inställningar</h2>
+
+      <h3>Ljud</h3>
+      <div class="prop-grid">
+        <label>Volym</label>
+        <div class="row">
+          <input id="setting-volume" type="range" min="0" max="100" step="1" style="flex:1"
+                 oninput="document.getElementById('volume-val').textContent=this.value+'%'">
+          <span id="volume-val" style="color:#aaa;min-width:3rem;text-align:right">—</span>
+          <button onclick="setVolym()">Sätt</button>
+        </div>
+
+        <label>Ljudprofil</label>
+        <select id="setting-audio"></select>
+      </div>
+
+      <h3>Display</h3>
+      <div class="prop-grid">
+        <label>Intern display</label>
+        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+          <input type="checkbox" id="setting-internal-display" checked onchange="setInternDisplay(this.checked)">
+          Aktivera eDP-1 med admin-gränssnitt
+        </label>
+
+        <label>HDMI-utgång</label>
+        <select id="setting-display">
+          <option value="last">Sista anslutna (standard)</option>
+          <option value="first">Första anslutna</option>
+        </select>
+
+        <label>Upplösning</label>
+        <div style="display:flex;gap:.5rem;align-items:center">
+          <select id="setting-display-mode" style="flex:1">
+            <option value="">(Ingen / lämna tomt)</option>
+          </select>
+          <input id="setting-display-mode-custom" type="text" placeholder="Ex: 1920x1080" style="display:none;min-width:10rem">
+        </div>
+      </div>
+      <div id="displays-info" style="font-size:.82rem;color:#555;margin:.2rem 0 .75rem"></div>
+
+      <div class="row">
+        <button onclick="sparaInstallningar()">Spara inställningar</button>
+        <button class="secondary" onclick="applyDisplayNow()">Tillämpa upplösning nu</button>
+        <span id="settings-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+    </section>
+
+    <!-- Sektion 6: System -->
+    <section>
+      <h2>System</h2>
+      <div class="row">
+        <button class="secondary" onclick="startaOmProgram()">Starta om programmet</button>
+        <button class="danger" onclick="startaOmSystemet()">Starta om datorn</button>
+        <button class="danger" onclick="stangAvDatorn()">Stäng av datorn</button>
+      </div>
+    </section>
+
+    <!-- Sektion 7: Systeminfo -->
+    <section>
+      <h2>Systeminfo</h2>
+      <div id="sysinfo" class="sysinfo-grid">
+        <div class="stat"><span class="label">CPU</span><span id="cpu">—</span></div>
+        <div class="stat"><span class="label">RAM</span><span id="ram">—</span></div>
+        <div class="stat"><span class="label">Disk</span><span id="disk">—</span></div>
+        <div class="stat"><span class="label">Uptime</span><span id="uptime">—</span></div>
+        <div class="stat wide"><span class="label">Aktiv källa</span><span id="active-source">—</span></div>
+      </div>
+    </section>
+  </div>
+
+  <!-- Filbläddrare -->
+  <div id="browser-modal" class="modal-overlay" style="display:none" onclick="browserOverlayClick(event)">
+    <div class="modal">
+      <div class="modal-header">
+        <span class="modal-path" id="browser-path"></span>
+        <button class="secondary" onclick="closeBrowser()">✕</button>
+      </div>
+      <div class="modal-shortcuts">
+        <button class="secondary" onclick="browseTo('/home/mrfox')">Hem</button>
+        <button class="secondary" onclick="browseTo('/media')">USB (/media)</button>
+        <button class="secondary" onclick="browseTo('/mnt')">Monterat (/mnt)</button>
+      </div>
+      <div id="browser-list" class="browser-list"></div>
+    </div>
+  </div>
+
+  <script src="/static/socket.io.min.js"></script>
+  <script src="/static/admin.js"></script>
+</body>
+</html>
diff --git a/kiosk/templates/chat_display.html b/kiosk/templates/chat_display.html
new file mode 100644
index 0000000..6f22f35
--- /dev/null
+++ b/kiosk/templates/chat_display.html
@@ -0,0 +1,258 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+  <meta charset="UTF-8">
+  <title>Kiosk Livechat</title>
+  <script src="/static/socket.io.min.js"></script>
+  <style>
+    * { box-sizing: border-box; margin: 0; padding: 0; }
+
+    html, body {
+      width: 100%; height: 100%;
+      background: #0f0f0f;
+      color: #e8e8e8;
+      font-family: 'Segoe UI', system-ui, sans-serif;
+      overflow: hidden;
+    }
+
+    /* Layout — chat left, QR panel right */
+    #root {
+      display: flex;
+      height: 100vh;
+      gap: 0;
+    }
+
+    /* === Chat column === */
+    #chat-col {
+      flex: 1;
+      display: flex;
+      flex-direction: column;
+      padding: 1.2rem 1rem 1rem 1.4rem;
+      overflow: hidden;
+    }
+
+    #chat-header {
+      display: flex;
+      align-items: center;
+      gap: .75rem;
+      margin-bottom: .9rem;
+      flex-shrink: 0;
+    }
+
+    #chat-header h1 {
+      font-size: 1.35rem;
+      font-weight: 700;
+      color: #fff;
+      letter-spacing: .02em;
+    }
+
+    #viewer-count {
+      font-size: .85rem;
+      color: #888;
+      background: #1a1a1a;
+      padding: .2rem .6rem;
+      border-radius: 99px;
+    }
+
+    /* Message list — fills remaining height, scrolls from bottom */
+    #messages {
+      flex: 1;
+      overflow: hidden;
+      position: relative;
+    }
+
+    #msg-inner {
+      position: absolute;
+      bottom: 0; left: 0; right: 0;
+      display: flex;
+      flex-direction: column;
+      gap: .55rem;
+      padding-bottom: .2rem;
+    }
+
+    .msg {
+      display: flex;
+      align-items: flex-start;
+      gap: .55rem;
+      animation: slide-in .25s ease;
+    }
+
+    @keyframes slide-in {
+      from { opacity: 0; transform: translateY(8px); }
+      to   { opacity: 1; transform: translateY(0);   }
+    }
+
+    .msg-avatar {
+      width: 2rem; height: 2rem;
+      border-radius: 50%;
+      display: flex; align-items: center; justify-content: center;
+      font-size: 1rem;
+      flex-shrink: 0;
+      margin-top: .1rem;
+    }
+
+    .msg-body { flex: 1; min-width: 0; }
+
+    .msg-name {
+      font-size: .78rem;
+      font-weight: 700;
+      margin-bottom: .15rem;
+    }
+
+    .msg-text {
+      font-size: 1.05rem;
+      line-height: 1.45;
+      word-break: break-word;
+      color: #e8e8e8;
+    }
+
+    .msg.system .msg-text {
+      font-size: .82rem;
+      color: #666;
+      font-style: italic;
+    }
+
+    /* === QR panel === */
+    #qr-col {
+      width: 180px;
+      flex-shrink: 0;
+      background: #181818;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      gap: .9rem;
+      padding: 1.2rem .8rem;
+      border-left: 1px solid #222;
+    }
+
+    #qr-canvas {
+      border-radius: 8px;
+      background: #fff;
+      padding: 6px;
+    }
+
+    #qr-label {
+      font-size: .72rem;
+      color: #666;
+      text-align: center;
+      word-break: break-all;
+    }
+
+    #qr-title {
+      font-size: .85rem;
+      font-weight: 600;
+      color: #aaa;
+      text-align: center;
+    }
+
+    .dot-live {
+      width: .55rem; height: .55rem;
+      background: #ef4444;
+      border-radius: 50%;
+      display: inline-block;
+      animation: pulse 1.4s infinite;
+    }
+
+    @keyframes pulse {
+      0%,100% { opacity: 1; }
+      50%      { opacity: .3; }
+    }
+  </style>
+</head>
+<body>
+<div id="root">
+
+  <!-- Chat column -->
+  <div id="chat-col">
+    <div id="chat-header">
+      <span class="dot-live"></span>
+      <h1>Livechat</h1>
+      <span id="viewer-count">0 aktiva</span>
+    </div>
+    <div id="messages">
+      <div id="msg-inner"></div>
+    </div>
+  </div>
+
+  <!-- QR panel -->
+  <div id="qr-col">
+    <div id="qr-title">Delta i chatten</div>
+    <img id="qr-img" src="/api/chat-qr" alt="QR" style="width:140px;height:140px;border-radius:8px;background:#fff;display:block">
+    <div id="qr-label">{{ local_ip }}:5000/chat-mobile</div>
+  </div>
+
+</div>
+
+<script>
+// I overlay-läge (?overlay=1) döljs QR-panelen och bakgrunden görs transparent
+const isOverlay = new URLSearchParams(location.search).has('overlay');
+if (isOverlay) {
+  document.body.style.background = 'transparent';
+  document.getElementById('root').style.background = 'transparent';
+  document.getElementById('qr-col').style.display  = 'none';
+  document.getElementById('chat-col').style.padding = '.5rem .75rem';
+}
+
+const MOBILE_URL = 'http://{{ local_ip }}:5000/chat-mobile';
+const MAX_MSGS   = 40;
+
+// SocketIO — /chat namespace
+const socket = io('/chat');
+
+const msgInner   = document.getElementById('msg-inner');
+const countEl    = document.getElementById('viewer-count');
+
+function avatarBg(color) {
+  return `background:${color}22; color:${color};`;
+}
+
+function avatarContent(emoji, name, avatarUrl) {
+  if (avatarUrl) return `<img src="${avatarUrl}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
+  return escHtml(emoji || name.charAt(0).toUpperCase());
+}
+
+function addMsg(name, text, color, emoji, avatarUrl) {
+  const div = document.createElement('div');
+  div.className = 'msg';
+  div.innerHTML = `
+    <div class="msg-avatar" style="${avatarBg(color)}">${avatarContent(emoji, name, avatarUrl)}</div>
+    <div class="msg-body">
+      <div class="msg-name" style="color:${color}">${escHtml(name)}</div>
+      <div class="msg-text">${escHtml(text)}</div>
+    </div>`;
+  msgInner.appendChild(div);
+  while (msgInner.children.length > MAX_MSGS) msgInner.removeChild(msgInner.firstChild);
+}
+
+function addSystem(text) {
+  const div = document.createElement('div');
+  div.className = 'msg system';
+  div.innerHTML = `<div class="msg-body"><div class="msg-text">${escHtml(text)}</div></div>`;
+  msgInner.appendChild(div);
+  while (msgInner.children.length > MAX_MSGS) msgInner.removeChild(msgInner.firstChild);
+}
+
+function escHtml(s) {
+  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+}
+
+socket.on('chat_history', (msgs) => {
+  msgInner.innerHTML = '';
+  msgs.forEach(m => addMsg(m.name, m.text, m.color, m.emoji, m.avatar_url));
+});
+
+socket.on('chat_message', (d) => {
+  addMsg(d.name, d.text, d.color, d.emoji, d.avatar_url);
+});
+
+socket.on('system_message', (d) => {
+  addSystem(d.text);
+});
+
+socket.on('viewer_count', (d) => {
+  countEl.textContent = d.count + ' aktiva';
+});
+</script>
+</body>
+</html>
diff --git a/kiosk/templates/chat_mobile.html b/kiosk/templates/chat_mobile.html
new file mode 100644
index 0000000..96d6880
--- /dev/null
+++ b/kiosk/templates/chat_mobile.html
@@ -0,0 +1,667 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
+  <title>Livechat</title>
+  <script src="/static/socket.io.min.js"></script>
+  <style>
+    * { box-sizing: border-box; margin: 0; padding: 0; }
+
+    :root {
+      --bg:       #0f0f0f;
+      --surface:  #1a1a1a;
+      --border:   #2a2a2a;
+      --text:     #e8e8e8;
+      --muted:    #777;
+      --accent:   #2563eb;
+      --accent-h: #1d4ed8;
+    }
+
+    html, body {
+      height: 100%;
+      background: var(--bg);
+      color: var(--text);
+      font-family: 'Segoe UI', system-ui, sans-serif;
+      font-size: 15px;
+    }
+
+    /* ====== AUTH SCREEN ====== */
+    #auth-screen {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      min-height: 100vh;
+      padding: 2rem 1.5rem;
+      gap: 1rem;
+    }
+
+    #auth-screen h1 {
+      font-size: 1.6rem;
+      font-weight: 700;
+      color: #fff;
+      margin-bottom: .25rem;
+    }
+
+    /* Tab bar */
+    .tab-bar {
+      display: flex;
+      width: 100%;
+      max-width: 340px;
+      background: var(--surface);
+      border-radius: 10px;
+      padding: .25rem;
+      gap: .25rem;
+    }
+
+    .tab-btn {
+      flex: 1;
+      padding: .6rem;
+      border: none;
+      border-radius: 8px;
+      background: transparent;
+      color: var(--muted);
+      font-size: .95rem;
+      font-weight: 600;
+      cursor: pointer;
+      transition: background .15s, color .15s;
+    }
+
+    .tab-btn.active {
+      background: var(--accent);
+      color: #fff;
+    }
+
+    /* Forms */
+    .auth-form {
+      display: none;
+      flex-direction: column;
+      gap: .75rem;
+      width: 100%;
+      max-width: 340px;
+    }
+
+    .auth-form.visible { display: flex; }
+
+    input[type=text], input[type=password] {
+      width: 100%;
+      background: var(--surface);
+      border: 1px solid var(--border);
+      color: var(--text);
+      padding: .75rem 1rem;
+      border-radius: 10px;
+      font-size: 1rem;
+    }
+
+    input::placeholder { color: var(--muted); }
+    input:focus { outline: 2px solid var(--accent); border-color: transparent; }
+
+    .auth-btn {
+      width: 100%;
+      background: var(--accent);
+      color: #fff;
+      border: none;
+      padding: .85rem 1rem;
+      border-radius: 10px;
+      font-size: 1rem;
+      font-weight: 600;
+      cursor: pointer;
+      margin-top: .25rem;
+    }
+
+    .auth-btn:hover { background: var(--accent-h); }
+    .auth-btn:disabled { opacity: .5; cursor: default; }
+
+    .auth-error {
+      color: #f87171;
+      font-size: .85rem;
+      min-height: 1.2em;
+      text-align: center;
+    }
+
+    /* Avatar grid (register only) */
+    .avatar-label {
+      font-size: .8rem;
+      color: var(--muted);
+      font-weight: 600;
+      letter-spacing: .04em;
+      text-transform: uppercase;
+    }
+
+    .avatar-pick {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      gap: .6rem;
+      width: 100%;
+    }
+
+    .avatar-pick-circle {
+      width: 80px; height: 80px;
+      border-radius: 50%;
+      background: var(--surface);
+      border: 2px dashed var(--border);
+      display: flex; align-items: center; justify-content: center;
+      overflow: hidden;
+      cursor: pointer;
+      transition: border-color .15s;
+    }
+
+    .avatar-pick-circle:hover { border-color: var(--accent); }
+    .avatar-pick-circle img { width: 100%; height: 100%; object-fit: cover; display: none; }
+    .avatar-pick-circle .pick-icon { font-size: 2rem; color: var(--muted); }
+
+    .avatar-pick-hint {
+      font-size: .8rem;
+      color: var(--muted);
+      text-align: center;
+    }
+
+    /* ====== CHAT SCREEN ====== */
+    #chat-screen {
+      display: none;
+      flex-direction: column;
+      height: 100vh;
+    }
+
+    #chat-topbar {
+      display: flex;
+      align-items: center;
+      gap: .6rem;
+      padding: .75rem 1rem;
+      background: var(--surface);
+      border-bottom: 1px solid var(--border);
+      flex-shrink: 0;
+    }
+
+    #chat-topbar .me-avatar { font-size: 1.3rem; }
+    #chat-topbar .me-name   { font-weight: 600; font-size: .95rem; }
+
+    #chat-topbar .viewers {
+      margin-left: auto;
+      font-size: .8rem;
+      color: var(--muted);
+      background: var(--bg);
+      padding: .2rem .6rem;
+      border-radius: 99px;
+    }
+
+    #logout-btn {
+      background: none;
+      border: none;
+      color: var(--muted);
+      font-size: 1.1rem;
+      cursor: pointer;
+      padding: .2rem .35rem;
+      border-radius: 6px;
+    }
+    #logout-btn:hover { color: #f87171; }
+
+    #avatar-upload-btn {
+      position: relative;
+      width: 2rem; height: 2rem;
+      border-radius: 50%;
+      overflow: hidden;
+      flex-shrink: 0;
+      cursor: pointer;
+      border: 2px solid #333;
+    }
+    #avatar-upload-btn img {
+      width: 100%; height: 100%;
+      object-fit: cover;
+    }
+    #avatar-upload-btn .avatar-overlay {
+      display: none;
+      position: absolute;
+      inset: 0;
+      background: #0009;
+      font-size: .65rem;
+      color: #fff;
+      align-items: center;
+      justify-content: center;
+    }
+    #avatar-upload-btn:hover .avatar-overlay { display: flex; }
+    #avatar-file { display: none; }
+
+    #chat-log {
+      flex: 1;
+      overflow-y: auto;
+      padding: .75rem 1rem;
+      display: flex;
+      flex-direction: column;
+      gap: .55rem;
+    }
+
+    .cm { display: flex; gap: .5rem; align-items: flex-start; }
+    .cm.mine { flex-direction: row-reverse; }
+
+    .cm-avatar {
+      width: 1.9rem; height: 1.9rem;
+      border-radius: 50%;
+      display: flex; align-items: center; justify-content: center;
+      font-size: 1rem;
+      flex-shrink: 0;
+    }
+
+    .cm-bubble {
+      max-width: 78%;
+      padding: .5rem .75rem;
+      border-radius: 14px;
+      font-size: .95rem;
+      line-height: 1.4;
+      word-break: break-word;
+    }
+
+    .cm:not(.mine) .cm-bubble {
+      background: var(--surface);
+      border-top-left-radius: 4px;
+    }
+
+    .cm.mine .cm-bubble {
+      background: var(--accent);
+      color: #fff;
+      border-top-right-radius: 4px;
+    }
+
+    .cm-name {
+      font-size: .72rem;
+      font-weight: 700;
+      margin-bottom: .15rem;
+    }
+
+    .cm.mine .cm-name { display: none; }
+
+    .cm.system { justify-content: center; }
+    .cm.system .cm-bubble {
+      background: transparent;
+      color: var(--muted);
+      font-size: .8rem;
+      font-style: italic;
+      text-align: center;
+      padding: .2rem;
+    }
+
+    #input-row {
+      display: flex;
+      gap: .5rem;
+      padding: .65rem .75rem;
+      background: var(--surface);
+      border-top: 1px solid var(--border);
+      flex-shrink: 0;
+    }
+
+    #msg-input {
+      flex: 1;
+      background: var(--bg);
+      border: 1px solid var(--border);
+      color: var(--text);
+      padding: .6rem .85rem;
+      border-radius: 20px;
+      font-size: .95rem;
+      resize: none;
+      min-height: 2.4rem;
+      max-height: 6rem;
+      overflow-y: auto;
+      line-height: 1.4;
+      font-family: inherit;
+    }
+
+    #msg-input::placeholder { color: var(--muted); }
+    #msg-input:focus { outline: 2px solid var(--accent); border-color: transparent; }
+
+    #send-btn {
+      width: 2.4rem; height: 2.4rem;
+      border-radius: 50%;
+      background: var(--accent);
+      color: #fff;
+      border: none;
+      font-size: 1.1rem;
+      cursor: pointer;
+      flex-shrink: 0;
+      align-self: flex-end;
+      display: flex; align-items: center; justify-content: center;
+    }
+
+    #send-btn:disabled { background: #333; color: #555; cursor: default; }
+    #send-btn:not(:disabled):hover { background: var(--accent-h); }
+  </style>
+</head>
+<body>
+
+<!-- AUTH SCREEN -->
+<div id="auth-screen">
+  <h1>💬 Livechat</h1>
+
+  <div class="tab-bar">
+    <button class="tab-btn active" id="tab-login-btn"    onclick="showTab('login')">Logga in</button>
+    <button class="tab-btn"        id="tab-register-btn" onclick="showTab('register')">Registrera</button>
+  </div>
+
+  <!-- Login form -->
+  <form class="auth-form visible" id="login-form" onsubmit="return false">
+    <input type="text"     id="login-username" placeholder="Användarnamn" autocomplete="username" autocorrect="off" autocapitalize="none">
+    <input type="password" id="login-password" placeholder="Lösenord"     autocomplete="current-password">
+    <div class="auth-error" id="login-error"></div>
+    <button class="auth-btn" id="login-btn" onclick="doLogin()">Logga in</button>
+  </form>
+
+  <!-- Register form -->
+  <form class="auth-form" id="register-form" onsubmit="return false">
+    <input type="text"     id="reg-username" placeholder="Välj användarnamn" autocomplete="username"     autocorrect="off" autocapitalize="none" maxlength="32">
+    <input type="password" id="reg-password" placeholder="Välj lösenord"     autocomplete="new-password">
+    <div class="avatar-label">Profilbild (obligatorisk)</div>
+    <div class="avatar-pick">
+      <div class="avatar-pick-circle" id="reg-avatar-circle" onclick="document.getElementById('reg-avatar-file').click()">
+        <img id="reg-avatar-preview" alt="">
+        <span class="pick-icon" id="reg-avatar-icon">📷</span>
+      </div>
+      <div class="avatar-pick-hint">Tryck för att välja bild från kameran eller bildbiblioteket</div>
+      <input type="file" id="reg-avatar-file" accept="image/*" capture="environment" style="display:none">
+    </div>
+    <div class="auth-error" id="reg-error"></div>
+    <button class="auth-btn" id="reg-btn" onclick="doRegister()">Skapa konto</button>
+  </form>
+</div>
+
+<!-- CHAT SCREEN -->
+<div id="chat-screen">
+  <div id="chat-topbar">
+    <div id="avatar-upload-btn" title="Byt profilbild" onclick="document.getElementById('avatar-file').click()">
+      <img id="me-avatar-img" src="" alt="" style="display:none">
+      <span id="me-avatar" style="font-size:1.3rem;display:flex;align-items:center;justify-content:center;width:100%;height:100%;"></span>
+      <div class="avatar-overlay">📷</div>
+    </div>
+    <input type="file" id="avatar-file" accept="image/jpeg,image/png,image/webp">
+    <span class="me-name"   id="me-name"></span>
+    <span class="viewers"   id="viewer-count">0 aktiva</span>
+    <button id="logout-btn" title="Logga ut" onclick="doLogout()">✕</button>
+  </div>
+
+  <div id="chat-log"></div>
+
+  <div id="input-row">
+    <textarea id="msg-input" placeholder="Skriv ett meddelande..." rows="1"></textarea>
+    <button id="send-btn" disabled>➤</button>
+  </div>
+</div>
+
+<script>
+const AVATARS = ['😀','😎','🤖','🦊','🐼','🐸','🦁','🐯','🦄','🐻',
+                 '🌟','🔥','⚡','🎮','🎵','🍕','🚀','💎','🌈','👾'];
+
+const TOKEN_KEY = 'kiosk_chat_token';
+const socket    = io('/chat');
+let currentUser = null;  // {username, emoji, color, token}
+
+// ---- Avatar pick (register) ----
+let regAvatarFile = null;
+
+document.getElementById('reg-avatar-file').addEventListener('change', (e) => {
+  const file = e.target.files[0];
+  if (!file) return;
+  regAvatarFile = file;
+  const preview = document.getElementById('reg-avatar-preview');
+  const icon    = document.getElementById('reg-avatar-icon');
+  const url = URL.createObjectURL(file);
+  preview.src = url;
+  preview.style.display = 'block';
+  icon.style.display    = 'none';
+});
+
+// ---- Tab switching ----
+function showTab(tab) {
+  document.getElementById('login-form').classList.toggle('visible',    tab === 'login');
+  document.getElementById('register-form').classList.toggle('visible', tab === 'register');
+  document.getElementById('tab-login-btn').classList.toggle('active',    tab === 'login');
+  document.getElementById('tab-register-btn').classList.toggle('active', tab === 'register');
+  document.getElementById('login-error').textContent = '';
+  document.getElementById('reg-error').textContent   = '';
+  // Reset avatar picker when switching tabs
+  regAvatarFile = null;
+  document.getElementById('reg-avatar-preview').style.display = 'none';
+  document.getElementById('reg-avatar-preview').src = '';
+  document.getElementById('reg-avatar-icon').style.display = '';
+  document.getElementById('reg-avatar-file').value = '';
+}
+
+// ---- Auth helpers ----
+function escHtml(s) {
+  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
+}
+
+async function doLogin() {
+  const username = document.getElementById('login-username').value.trim();
+  const password = document.getElementById('login-password').value;
+  const errEl    = document.getElementById('login-error');
+  if (!username || !password) { errEl.textContent = 'Fyll i alla fält'; return; }
+  document.getElementById('login-btn').disabled = true;
+  try {
+    const r = await fetch('/api/chat/login', {
+      method: 'POST',
+      headers: {'Content-Type': 'application/json'},
+      body: JSON.stringify({username, password}),
+    });
+    const d = await r.json();
+    if (!r.ok) { errEl.textContent = d.error || 'Inloggning misslyckades'; return; }
+    localStorage.setItem(TOKEN_KEY, d.token);
+    enterChat(d);
+  } catch {
+    errEl.textContent = 'Nätverksfel, försök igen';
+  } finally {
+    document.getElementById('login-btn').disabled = false;
+  }
+}
+
+async function doRegister() {
+  const username = document.getElementById('reg-username').value.trim();
+  const password = document.getElementById('reg-password').value;
+  const errEl    = document.getElementById('reg-error');
+  if (!username || !password) { errEl.textContent = 'Fyll i alla fält'; return; }
+  if (!regAvatarFile) { errEl.textContent = 'Välj en profilbild för att fortsätta'; return; }
+  document.getElementById('reg-btn').disabled = true;
+  try {
+    const randomEmoji = AVATARS[Math.floor(Math.random() * AVATARS.length)];
+    const r = await fetch('/api/chat/register', {
+      method: 'POST',
+      headers: {'Content-Type': 'application/json'},
+      body: JSON.stringify({username, password, emoji: randomEmoji}),
+    });
+    const d = await r.json();
+    if (!r.ok) { errEl.textContent = d.error || 'Registrering misslyckades'; return; }
+    // Upload avatar immediately after registration
+    const fd = new FormData();
+    fd.append('token', d.token);
+    fd.append('avatar', regAvatarFile);
+    const ar = await fetch('/api/chat/avatar', {method: 'POST', body: fd});
+    if (ar.ok) {
+      const ad = await ar.json();
+      d.avatar_url = ad.avatar_url;
+    }
+    localStorage.setItem(TOKEN_KEY, d.token);
+    enterChat(d);
+  } catch {
+    errEl.textContent = 'Nätverksfel, försök igen';
+  } finally {
+    document.getElementById('reg-btn').disabled = false;
+  }
+}
+
+function setMyAvatar(avatarUrl, emoji) {
+  const img  = document.getElementById('me-avatar-img');
+  const span = document.getElementById('me-avatar');
+  if (avatarUrl) {
+    img.src           = avatarUrl + '?t=' + Date.now();
+    img.style.display = 'block';
+    span.style.display = 'none';
+  } else {
+    img.style.display  = 'none';
+    span.style.display = 'flex';
+    span.textContent   = emoji || '?';
+  }
+}
+
+function enterChat(user) {
+  currentUser = user;
+  document.getElementById('auth-screen').style.display = 'none';
+  const cs = document.getElementById('chat-screen');
+  cs.style.display = 'flex';
+  setMyAvatar(user.avatar_url, user.emoji);
+  document.getElementById('me-name').textContent = user.username;
+  document.getElementById('msg-input').focus();
+  socket.emit('join', {token: user.token});
+}
+
+// ---- Avatar upload ----
+document.getElementById('avatar-file').addEventListener('change', async (e) => {
+  const file = e.target.files[0];
+  if (!file || !currentUser) return;
+  const fd = new FormData();
+  fd.append('token',  currentUser.token);
+  fd.append('avatar', file);
+  try {
+    const r = await fetch('/api/chat/avatar', {method: 'POST', body: fd});
+    if (!r.ok) return;
+    const d = await r.json();
+    currentUser.avatar_url = d.avatar_url;
+    setMyAvatar(d.avatar_url, currentUser.emoji);
+  } catch {}
+  e.target.value = '';
+});
+
+function doLogout() {
+  localStorage.removeItem(TOKEN_KEY);
+  currentUser = null;
+  socket.disconnect();
+  document.getElementById('chat-screen').style.display = 'none';
+  document.getElementById('auth-screen').style.display = 'flex';
+  document.getElementById('chat-log').innerHTML = '';
+  document.getElementById('login-password').value = '';
+  socket.connect();
+}
+
+// ---- Auto-login from stored token ----
+(async function tryAutoLogin() {
+  const token = localStorage.getItem(TOKEN_KEY);
+  if (!token) return;
+  try {
+    const r = await fetch('/api/chat/validate', {
+      method: 'POST',
+      headers: {'Content-Type': 'application/json'},
+      body: JSON.stringify({token}),
+    });
+    if (!r.ok) { localStorage.removeItem(TOKEN_KEY); return; }
+    const d = await r.json();
+    enterChat({...d, token});
+  } catch {}
+})();
+
+// ---- Socket events ----
+socket.on('chat_history', (msgs) => {
+  if (!currentUser) return;  // ignorera historik som anländer före inloggning
+  chatLog.innerHTML = '';
+  msgs.forEach(m => {
+    if (m.name === currentUser.username) addMyMsg(m.text);
+    else addOtherMsg(m);
+  });
+});
+
+socket.on('chat_message', (d) => {
+  if (!currentUser) return;
+  if (d.name !== currentUser.username) addOtherMsg(d);
+});
+
+socket.on('system_message', (d) => {
+  addSystemMsg(d.text);
+});
+
+socket.on('viewer_count', (d) => {
+  document.getElementById('viewer-count').textContent = d.count + ' aktiva';
+});
+
+socket.on('auth_error', (d) => {
+  localStorage.removeItem(TOKEN_KEY);
+  doLogout();
+  document.getElementById('login-error').textContent = d.error || 'Sessionen har löpt ut';
+  showTab('login');
+});
+
+// Re-join after reconnect
+socket.on('connect', () => {
+  if (currentUser) socket.emit('join', {token: currentUser.token});
+});
+
+// ---- Chat input ----
+const chatLog  = document.getElementById('chat-log');
+const msgInput = document.getElementById('msg-input');
+const sendBtn  = document.getElementById('send-btn');
+
+msgInput.addEventListener('input', () => {
+  sendBtn.disabled = msgInput.value.trim().length === 0;
+  msgInput.style.height = '';
+  msgInput.style.height = Math.min(msgInput.scrollHeight, 96) + 'px';
+});
+
+msgInput.addEventListener('keydown', (e) => {
+  if (e.key === 'Enter' && !e.shiftKey) {
+    e.preventDefault();
+    if (!sendBtn.disabled) sendBtn.click();
+  }
+});
+
+sendBtn.addEventListener('click', () => {
+  const text = msgInput.value.trim();
+  if (!text || !currentUser) return;
+  socket.emit('chat_message', {text});
+  addMyMsg(text);
+  msgInput.value = '';
+  msgInput.style.height = '';
+  sendBtn.disabled = true;
+});
+
+// ---- Message renderers ----
+function myAvatarHtml() {
+  if (currentUser.avatar_url) return `<img src="${currentUser.avatar_url}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
+  return escHtml(currentUser.emoji);
+}
+
+function addMyMsg(text) {
+  const div = document.createElement('div');
+  div.className = 'cm mine';
+  div.innerHTML = `
+    <div class="cm-avatar" style="background:#2563eb22;color:#60a5fa">${myAvatarHtml()}</div>
+    <div class="cm-bubble">${escHtml(text)}</div>`;
+  chatLog.appendChild(div);
+  chatLog.scrollTop = chatLog.scrollHeight;
+}
+
+function otherAvatarHtml(d) {
+  if (d.avatar_url) return `<img src="${d.avatar_url}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
+  return escHtml(d.emoji || d.name.charAt(0));
+}
+
+function addOtherMsg(d) {
+  const div = document.createElement('div');
+  div.className = 'cm';
+  div.innerHTML = `
+    <div class="cm-avatar" style="background:${d.color}22;color:${d.color}">${otherAvatarHtml(d)}</div>
+    <div>
+      <div class="cm-name" style="color:${d.color}">${escHtml(d.name)}</div>
+      <div class="cm-bubble">${escHtml(d.text)}</div>
+    </div>`;
+  chatLog.appendChild(div);
+  chatLog.scrollTop = chatLog.scrollHeight;
+}
+
+function addSystemMsg(text) {
+  const div = document.createElement('div');
+  div.className = 'cm system';
+  div.innerHTML = `<div class="cm-bubble">${escHtml(text)}</div>`;
+  chatLog.appendChild(div);
+  chatLog.scrollTop = chatLog.scrollHeight;
+}
+
+window.addEventListener('beforeunload', () => socket.disconnect());
+</script>
+</body>
+</html>
diff --git a/kiosk/templates/display.html b/kiosk/templates/display.html
new file mode 100644
index 0000000..2a03622
--- /dev/null
+++ b/kiosk/templates/display.html
@@ -0,0 +1,197 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Kiosk Display</title>
+  <style>
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+    html, body { width: 100%; height: 100%; overflow: hidden; background: #000; cursor: none; }
+    * { cursor: none !important; }
+    .layer { position: fixed; inset: 0; display: none; }
+    #layer-iframe iframe { width: 100%; height: 100%; border: none; }
+    #layer-video { background: #000; display: flex; align-items: center; justify-content: center; }
+    #layer-video video { width: 100%; height: 100%; object-fit: contain; }
+    #layer-image { background: #000; display: flex; align-items: center; justify-content: center; }
+    #layer-image img { width: 100%; height: 100%; object-fit: contain; }
+    #layer-idle { background: #111; display: flex; align-items: center; justify-content: center; }
+    #layer-idle .ip { color: #fff; font-family: monospace; font-size: 4vw; opacity: 0.6; }
+
+    /* Ticker overlay */
+    #ticker {
+      display: none;
+      position: fixed;
+      left: 0; right: 0;
+      z-index: 9999;
+      overflow: hidden;
+      pointer-events: none;
+    }
+    #ticker.top    { top: 0; }
+    #ticker.bottom { bottom: 0; }
+    #ticker-text {
+      display: inline-block;
+      white-space: nowrap;
+    }
+    @keyframes ticker-scroll {
+      from { transform: translateX(100vw); }
+      to   { transform: translateX(-100%); }
+    }
+    /* === Kiosk chat overlay === */
+    #kiosk-chat-overlay {
+      font-family: 'Segoe UI', system-ui, sans-serif;
+    }
+    #kc-msgs {
+      position: absolute;
+      bottom: 0; left: 0; right: 0;
+      display: flex;
+      flex-direction: column;
+      gap: .55rem;
+      padding: .75rem;
+    }
+    .kc-msg {
+      display: flex;
+      align-items: flex-start;
+      gap: .55rem;
+      animation: kc-in .25s ease;
+    }
+    .kc-avatar {
+      width: 2rem; height: 2rem;
+      border-radius: 50%;
+      display: flex; align-items: center; justify-content: center;
+      font-size: 1rem;
+      flex-shrink: 0;
+      margin-top: .1rem;
+    }
+    .kc-body { flex: 1; min-width: 0; }
+    .kc-name {
+      font-size: .78rem;
+      font-weight: 700;
+      margin-bottom: .15rem;
+    }
+    .kc-text {
+      font-size: 1.05rem;
+      line-height: 1.45;
+      word-break: break-word;
+      color: #e8e8e8;
+      text-shadow: 0 1px 4px #000c;
+    }
+    .kc-system {
+      font-size: .8rem;
+      color: #aaa;
+      font-style: italic;
+      padding: .05rem .25rem;
+      text-shadow: 0 1px 3px #000c;
+    }
+    @keyframes kc-in {
+      from { opacity: 0; transform: translateY(8px); }
+      to   { opacity: 1; transform: translateY(0);   }
+    }
+    @keyframes ticker-scroll-seamless {
+      from { transform: translateX(0); }
+      to   { transform: translateX(-50%); }
+    }
+    /* kiosk chat overlay header */
+    #kc-header {
+      display: flex;
+      align-items: center;
+      gap: .5rem;
+      padding: .55rem .75rem .45rem;
+      border-bottom: 1px solid #ffffff14;
+      flex-shrink: 0;
+    }
+    #kc-title {
+      font-size: .9rem;
+      font-weight: 700;
+      color: #fff;
+      letter-spacing: .02em;
+    }
+    #kc-count {
+      font-size: .72rem;
+      color: #888;
+      background: #1a1a1a;
+      padding: .15rem .5rem;
+      border-radius: 99px;
+      margin-right: auto;
+    }
+    #kc-qr {
+      width: 48px; height: 48px;
+      border-radius: 5px;
+      background: #fff;
+      padding: 2px;
+      flex-shrink: 0;
+    }
+    #kc-msgs-wrap {
+      flex: 1;
+      overflow: hidden;
+      position: relative;
+    }
+    .dot-live {
+      width: .5rem; height: .5rem;
+      background: #ef4444;
+      border-radius: 50%;
+      display: inline-block;
+      flex-shrink: 0;
+      animation: kc-pulse 1.4s infinite;
+    }
+    @keyframes kc-pulse {
+      0%,100% { opacity: 1; }
+      50%      { opacity: .3; }
+    }
+  </style>
+</head>
+<body>
+  <div id="layer-iframe" class="layer">
+    <iframe id="frame" src="" frameborder="0" allow="autoplay; fullscreen; picture-in-picture"></iframe>
+  </div>
+
+  <div id="layer-video" class="layer">
+    <video id="video" autoplay></video>
+  </div>
+
+  <div id="layer-local" class="layer"></div>
+
+  <div id="layer-image" class="layer">
+    <img id="image" src="" alt="">
+  </div>
+
+  <!-- YouTube Livechat overlay -->
+  <div id="chat-overlay" style="display:none; position:fixed; top:0; z-index:9998; pointer-events:auto; transition:opacity .3s;">
+    <iframe id="chat-frame" src="" frameborder="0" style="width:100%;height:100%;border:none;"></iframe>
+  </div>
+
+  <!-- Kiosk Livechat overlay (inbyggd, ingen iframe) -->
+  <div id="kiosk-chat-overlay" style="display:none; position:fixed; top:0; z-index:9997; pointer-events:none; overflow:hidden; font-family:'Segoe UI',system-ui,sans-serif; flex-direction:column; background:#0f0f0fd0;">
+    <div id="kc-header">
+      <span class="dot-live"></span>
+      <span id="kc-title">Livechat</span>
+      <span id="kc-count">0 aktiva</span>
+      <img id="kc-qr" src="/api/chat-qr" alt="QR">
+    </div>
+    <div id="kc-msgs-wrap">
+      <div id="kc-msgs" style="position:absolute;bottom:0;left:0;right:0;display:flex;flex-direction:column;gap:.45rem;padding:.6rem .75rem;"></div>
+    </div>
+  </div>
+
+  <!-- Ticker overlay -->
+  <div id="ticker" class="bottom">
+    <span id="ticker-text"></span>
+  </div>
+
+  <div id="layer-idle" class="layer" style="display: block;">
+    <span class="ip">{{ local_ip }}:5000</span>
+  </div>
+
+  <!-- Clock overlay -->
+  <div id="clock-overlay" style="display:none; position:fixed; z-index:9990; pointer-events:none; flex-direction:column; align-items:center;">
+    <div id="clock-digital"></div>
+    <canvas id="clock-analog"></canvas>
+    <div id="clock-date"></div>
+  </div>
+
+  <!-- Söv-overlay — täcker allt -->
+  <div id="layer-sleep" style="display:none; position:fixed; inset:0; background:#000; z-index:99999;"></div>
+
+  <script src="/static/socket.io.min.js"></script>
+  <script src="/static/display.js"></script>
+</body>
+</html>
diff --git a/kiosk/templates/login.html b/kiosk/templates/login.html
new file mode 100644
index 0000000..6964b2c
--- /dev/null
+++ b/kiosk/templates/login.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>Kiosk — Logga in</title>
+  <link rel="stylesheet" href="/static/style.css">
+  <style>
+    body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
+    .login-box { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 8px; padding: 2rem; width: 100%; max-width: 340px; }
+    h2 { color: #fff; margin-bottom: 1.5rem; }
+    .field { display: flex; flex-direction: column; gap: .4rem; margin-bottom: 1rem; }
+    label { font-size: .8rem; color: #888; text-transform: uppercase; letter-spacing: .06em; }
+    input { width: 100%; }
+    button { width: 100%; margin-top: .5rem; padding: .6rem; }
+    .error { color: #f87171; font-size: .875rem; margin-bottom: 1rem; }
+  </style>
+</head>
+<body>
+  <div class="login-box">
+    <h2>Logga in</h2>
+    {% if error %}<p class="error">{{ error }}</p>{% endif %}
+    <form method="post">
+      <div class="field">
+        <label>Användarnamn</label>
+        <input type="text" name="username" autofocus autocomplete="username">
+      </div>
+      <div class="field">
+        <label>Lösenord</label>
+        <input type="password" name="password" autocomplete="current-password">
+      </div>
+      <button type="submit">Logga in</button>
+    </form>
+  </div>
+</body>
+</html>
diff --git a/start.sh b/start.sh
new file mode 100755
index 0000000..17cbfb1
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,78 @@
+#!/bin/bash
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+KIOSK_DIR="$SCRIPT_DIR/kiosk"
+PID_FILE="/tmp/kiosk.pids"
+
+export KIOSK_USER="${KIOSK_USER:-mrfox}"
+export KIOSK_PASS="${KIOSK_PASS:-qweasdzxc}"
+export SECRET_KEY="${SECRET_KEY:-kiosk-hemlig-nyckel-byt-garna}"
+
+cleanup() {
+  echo "Stänger ner..."
+  kill "$FLASK_PID" 2>/dev/null
+  kill "$CAGE_PID"  2>/dev/null
+  rm -f "$PID_FILE"
+  wait 2>/dev/null
+  exit 0
+}
+trap cleanup SIGINT SIGTERM
+
+while true; do
+  # Läs konfiguration
+  KIOSK_AUDIO_PROFILE_INDEX=1
+  KIOSK_DISPLAY_OUTPUT="last"
+  [ -f "$SCRIPT_DIR/kiosk.conf" ] && source "$SCRIPT_DIR/kiosk.conf"
+  [[ "$KIOSK_DISPLAY_OUTPUT" != "first" && "$KIOSK_DISPLAY_OUTPUT" != "last" ]] && KIOSK_DISPLAY_OUTPUT="last"
+  if [ "$KIOSK_AUDIO_PROFILE_INDEX" != "1" ]; then
+    AUDIO_DEV=$(pw-dump 2>/dev/null | python3 -c "
+import json,sys
+for n in json.load(sys.stdin):
+    if n.get('type')=='PipeWire:Interface:Device':
+        p=n.get('info',{}).get('props',{})
+        if p.get('media.class')=='Audio/Device':
+            print(n['id']); break
+" 2>/dev/null)
+    [ -n "$AUDIO_DEV" ] && pw-cli s "$AUDIO_DEV" Profile "{ index: $KIOSK_AUDIO_PROFILE_INDEX }" 2>/dev/null && sleep 0.3
+  fi
+
+  echo "Startar Flask..."
+  cd "$KIOSK_DIR"
+  python3 app.py &
+  FLASK_PID=$!
+
+  echo "Väntar på Flask..."
+  until curl -sf -o /dev/null http://127.0.0.1:5000/display; do
+    sleep 1
+    kill -0 "$FLASK_PID" 2>/dev/null || break
+  done
+
+  kill -0 "$FLASK_PID" 2>/dev/null || { echo "Flask kraschade, startar om..."; sleep 2; continue; }
+
+  echo "Flask kör. Startar Cage + Chromium..."
+  cage -m "$KIOSK_DISPLAY_OUTPUT" -- chromium \
+    --kiosk \
+    --no-sandbox \
+    --disable-translate \
+    --disable-web-security \
+    --enable-features=VaapiVideoDecoder,VaapiVideoEncoder \
+    --disable-features=UseChromeOSDirectVideoDecoder \
+    --user-data-dir=/tmp/chromium-kiosk \
+    --disable-infobars \
+    --disable-session-crashed-bubble \
+    --disable-features=Translate \
+    --no-first-run \
+    --autoplay-policy=no-user-gesture-required \
+    --ozone-platform=wayland \
+    http://localhost:5000/display &
+  CAGE_PID=$!
+
+  echo "$FLASK_PID $CAGE_PID" > "$PID_FILE"
+  echo "Kiosk igång. Ctrl+C för att stänga."
+
+  wait "$CAGE_PID"
+  echo "Cage avslutades, startar om..."
+  kill "$FLASK_PID" 2>/dev/null
+  wait "$FLASK_PID" 2>/dev/null
+  sleep 2
+done
diff --git a/sudoers-kiosk b/sudoers-kiosk
new file mode 100644
index 0000000..4519f30
--- /dev/null
+++ b/sudoers-kiosk
@@ -0,0 +1 @@
+mrfox ALL=(ALL) NOPASSWD: /sbin/reboot, /usr/sbin/reboot