diff --git a/README.md b/README.md
index 8148dae..edd1f16 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,62 @@ A feature-rich PHP-based ticketing system designed for tracking and managing dat
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
+
+
+## Why this exists
+
+Running a six-node Proxmox/Ceph cluster generates a steady stream of small problems: a drive with SMART warnings, a link that flaps, a certificate about to expire. Off-the-shelf helpdesks are heavyweight for that, and a plain todo list loses the history. Tinker Tickets is a small, opinionated ticketing system built for exactly this: tickets are created **automatically by monitoring** (and by hand), deduplicated so a repeating alert updates one ticket instead of flooding the queue, and every change is audited.
+
+```mermaid
+flowchart LR
+ hw["hwmonDaemon
SMART, Ceph, temps"] -->|Bearer API| tt
+ gd["GANDALF
network regressions"] -->|Bearer API| tt
+ mcp["MCP server
Claude Code / agents (OAuth)"] --> tt
+ ui["Web UI
people"] --> tt
+ tt{{"Tinker Tickets
PHP + MariaDB"}} -->|webhook| mx["Matrix notifications"]
+ tt --> audit[("Audit log
timeline per ticket")]
+```
+
+## Gallery
+
+**Dashboard**: live stat cards, priority and status breakdowns, team workload and a filterable queue.
+
+
+
+**Kanban view** with per-column counts and priority-coloured cards:
+
+
+
+**Ticket detail** with a live P1/P2 SLA timer, full metadata and a tabbed workspace:
+
+
+
+| Comments (Markdown, @mentions) | Dependencies (blocks / relates to) |
+|---|---|
+|  |  |
+
+**Activity timeline**, derived from the audit log (creation, assignment, status changes, comments):
+
+
+
+**Command palette** (`Ctrl+K`) jumps to pages, saved filters and recently viewed tickets:
+
+
+
+**Admin tools:**
+
+| Workflow designer | API keys (scoped, revocable) |
+|---|---|
+|  |  |
+| **Audit log** | **Recurring tickets** |
+|  |  |
+
+**Light theme** (the whole app follows the [web_template](https://code.lotusguild.org/LotusGuild/web_template) theme toggle):
+
+
+
+Screenshots are generated with Playwright against a throwaway local instance seeded with fictional demo data (`scripts/demo/`).
+
## Styling & Layout
Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
diff --git a/docs/img/admin-api-keys.png b/docs/img/admin-api-keys.png
new file mode 100644
index 0000000..9ffe0ad
Binary files /dev/null and b/docs/img/admin-api-keys.png differ
diff --git a/docs/img/admin-audit-log.png b/docs/img/admin-audit-log.png
new file mode 100644
index 0000000..b166932
Binary files /dev/null and b/docs/img/admin-audit-log.png differ
diff --git a/docs/img/admin-recurring.png b/docs/img/admin-recurring.png
new file mode 100644
index 0000000..0a02d8a
Binary files /dev/null and b/docs/img/admin-recurring.png differ
diff --git a/docs/img/admin-workflow.png b/docs/img/admin-workflow.png
new file mode 100644
index 0000000..3f32499
Binary files /dev/null and b/docs/img/admin-workflow.png differ
diff --git a/docs/img/command-palette.png b/docs/img/command-palette.png
new file mode 100644
index 0000000..1273a35
Binary files /dev/null and b/docs/img/command-palette.png differ
diff --git a/docs/img/dashboard-light.png b/docs/img/dashboard-light.png
new file mode 100644
index 0000000..59e7be2
Binary files /dev/null and b/docs/img/dashboard-light.png differ
diff --git a/docs/img/dashboard.png b/docs/img/dashboard.png
new file mode 100644
index 0000000..3e34fc6
Binary files /dev/null and b/docs/img/dashboard.png differ
diff --git a/docs/img/demo.gif b/docs/img/demo.gif
new file mode 100644
index 0000000..c14e880
Binary files /dev/null and b/docs/img/demo.gif differ
diff --git a/docs/img/kanban.png b/docs/img/kanban.png
new file mode 100644
index 0000000..03f55e1
Binary files /dev/null and b/docs/img/kanban.png differ
diff --git a/docs/img/new-ticket.png b/docs/img/new-ticket.png
new file mode 100644
index 0000000..f917e35
Binary files /dev/null and b/docs/img/new-ticket.png differ
diff --git a/docs/img/ticket-activity.png b/docs/img/ticket-activity.png
new file mode 100644
index 0000000..b04a7aa
Binary files /dev/null and b/docs/img/ticket-activity.png differ
diff --git a/docs/img/ticket-comments.png b/docs/img/ticket-comments.png
new file mode 100644
index 0000000..e754fd0
Binary files /dev/null and b/docs/img/ticket-comments.png differ
diff --git a/docs/img/ticket-dependencies.png b/docs/img/ticket-dependencies.png
new file mode 100644
index 0000000..614e285
Binary files /dev/null and b/docs/img/ticket-dependencies.png differ
diff --git a/docs/img/ticket-queue.png b/docs/img/ticket-queue.png
new file mode 100644
index 0000000..cf13fd0
Binary files /dev/null and b/docs/img/ticket-queue.png differ
diff --git a/docs/img/ticket-sla.png b/docs/img/ticket-sla.png
new file mode 100644
index 0000000..7dec738
Binary files /dev/null and b/docs/img/ticket-sla.png differ
diff --git a/scripts/demo/README.md b/scripts/demo/README.md
new file mode 100644
index 0000000..1067e9b
--- /dev/null
+++ b/scripts/demo/README.md
@@ -0,0 +1,19 @@
+# Demo environment (for README screenshots)
+
+Everything here uses **fictional data only**. It spins up a throwaway MariaDB on a private socket and PHP's
+built-in server, so nothing touches a real database.
+
+```bash
+D=$(mktemp -d); cp -r . $D/tinker && cp scripts/demo/router.php scripts/demo/seed.py scripts/demo/capture.py $D/
+cd $D
+printf 'DB_HOST=localhost\nDB_USER=root\nDB_PASS=\nDB_NAME=ticketing_system\n' > tinker/.env
+mariadb-install-db --no-defaults --datadir=$D/db/data --auth-root-authentication-method=normal --skip-test-db
+mariadbd --no-defaults --datadir=$D/db/data --socket=$D/db/sock --skip-networking --user=root &
+mariadb --no-defaults -S $D/db/sock -e 'CREATE DATABASE ticketing_system CHARACTER SET utf8mb4'
+(cd tinker && php -d mysqli.default_socket=$D/db/sock migrations/migrate.php)
+python3 seed.py && mariadb --no-defaults -S $D/db/sock ticketing_system < seed.sql
+php -d mysqli.default_socket=$D/db/sock -S 127.0.0.1:8300 -t tinker router.php &
+python3 capture.py # screenshots + GIF -> docs/img/
+```
+
+The app trusts Authelia `Remote-User` headers, so `capture.py` sends them directly. Requires `playwright` and `pillow`.
diff --git a/scripts/demo/capture.py b/scripts/demo/capture.py
new file mode 100644
index 0000000..30d2ece
--- /dev/null
+++ b/scripts/demo/capture.py
@@ -0,0 +1,78 @@
+import asyncio, io, sys
+from PIL import Image
+from playwright.async_api import async_playwright
+import pathlib
+OUT=str(pathlib.Path(__file__).resolve().parents[2]/'docs'/'img')
+B='http://127.0.0.1:8300'
+H={'Remote-User':'alex.rivera','Remote-Name':'Alex Rivera','Remote-Email':'alex@example.com','Remote-Groups':'admin,employee'}
+P1='152847156'; CEPH='524938499'
+JS_TOP="document.activeElement&&document.activeElement.blur();document.documentElement.style.scrollBehavior='auto';window.scrollTo(0,0)"
+async def ctxmk(p,theme='dark',w=1440,h=900):
+ b=await p.chromium.launch(); c=await b.new_context(viewport={'width':w,'height':h},extra_http_headers=H)
+ await c.add_init_script(f"try{{localStorage.setItem('lt_theme','{theme}')}}catch(e){{}}")
+ async def avatar(route):
+ import re
+ uid=int((re.search(r'user_id=(\d+)',route.request.url) or [0,0])[1] or 0)
+ ini={1:'AR',2:'SC',3:'RB',4:'JK',5:'HW'}.get(uid,'??'); col={1:'#ff6b00',2:'#00d4ff',3:'#b44aff',4:'#00ff88',5:'#ffc800'}.get(uid,'#888')
+ svg=f''
+ await route.fulfill(status=200,content_type='image/svg+xml',body=svg)
+ await c.route('**/api/user_avatar.php*',avatar)
+ return b,c
+async def go(pg,path,wait=3500):
+ await pg.goto(B+path); await pg.wait_for_timeout(wait); await pg.evaluate(JS_TOP); await pg.wait_for_timeout(300)
+async def tab(pg,name):
+ t=pg.get_by_role('tab',name=name).first
+ if await t.count()==0: t=pg.get_by_text(name,exact=False).first
+ await t.click(timeout=4000); await pg.wait_for_timeout(900)
+async def shots():
+ async with async_playwright() as p:
+ b,c=await ctxmk(p); pg=await c.new_page()
+ await go(pg,'/',5000); await pg.screenshot(path=f'{OUT}/dashboard.png')
+ # table crop
+ await pg.set_viewport_size({'width':1840,'height':900}); await pg.wait_for_timeout(600)
+ await pg.evaluate("document.querySelector('.lt-tabs, [role=tablist]')?.scrollIntoView({block:'start'})"); await pg.wait_for_timeout(500)
+ await pg.screenshot(path=f'{OUT}/ticket-queue.png')
+ await pg.set_viewport_size({'width':1440,'height':900}); await pg.wait_for_timeout(600)
+ try: await tab(pg,'Kanban'); await pg.screenshot(path=f'{OUT}/kanban.png'); print('kanban ok')
+ except Exception as e: print('kanban fail',str(e)[:80])
+ await go(pg,'/ticket/'+P1); await pg.screenshot(path=f'{OUT}/ticket-sla.png')
+ await go(pg,'/ticket/'+CEPH)
+ for name,fn in (('Comments','ticket-comments'),('Dependencies','ticket-dependencies'),('Activity','ticket-activity')):
+ try: await tab(pg,name); await pg.evaluate("document.querySelector('.lt-tabs, [role=tablist]')?.scrollIntoView({block:'start'})"); await pg.wait_for_timeout(500); await pg.screenshot(path=f'{OUT}/{fn}.png'); print(fn,'ok')
+ except Exception as e: print(fn,'fail',str(e)[:80])
+ await go(pg,'/ticket/create',3000); await pg.screenshot(path=f'{OUT}/new-ticket.png')
+ await go(pg,'/'); await pg.keyboard.press('Control+k'); await pg.wait_for_timeout(500); await pg.keyboard.type('ticket',delay=70); await pg.wait_for_timeout(900)
+ await pg.screenshot(path=f'{OUT}/command-palette.png', clip={'x':320,'y':60,'width':800,'height':460})
+ await pg.keyboard.press('Escape')
+ for path,name in (('/admin/workflow','admin-workflow'),('/admin/api-keys','admin-api-keys'),('/admin/audit-log','admin-audit-log'),('/admin/recurring-tickets','admin-recurring')):
+ await go(pg,path,3500); await pg.screenshot(path=f'{OUT}/{name}.png'); print(name,'ok')
+ await b.close()
+ b,c=await ctxmk(p,'light'); pg=await c.new_page(); await go(pg,'/',5000); await pg.screenshot(path=f'{OUT}/dashboard-light.png'); await b.close()
+async def gif():
+ frames=[]
+ async with async_playwright() as p:
+ b,c=await ctxmk(p,'dark',1280,720); pg=await c.new_page()
+ async def snap(n=1,ms=0):
+ if ms: await pg.wait_for_timeout(ms)
+ im=Image.open(io.BytesIO(await pg.screenshot())).convert('RGB').resize((960,540),Image.LANCZOS)
+ for _ in range(n): frames.append(im)
+ await go(pg,'/',4500); await snap(3)
+ await pg.mouse.wheel(0,520); await snap(2,500)
+ try: await tab(pg,'Kanban'); await snap(3,300)
+ except Exception: pass
+ await go(pg,'/ticket/'+P1,3000); await snap(3)
+ await go(pg,'/ticket/'+CEPH,3000); await snap(2)
+ for name in ('Comments','Dependencies','Activity'):
+ try: await tab(pg,name); await snap(3,300)
+ except Exception: pass
+ await go(pg,'/',3000); await pg.keyboard.press('Control+k'); await snap(2,500)
+ for ch in 'tick': await pg.keyboard.type(ch); await snap(1,200)
+ await snap(3,300)
+ await go(pg,'/admin/workflow',3000); await snap(3)
+ await b.close()
+ pal=[f.quantize(colors=96,method=Image.MEDIANCUT,dither=Image.NONE) for f in frames]
+ pal[0].save(f'{OUT}/demo.gif',save_all=True,append_images=pal[1:],duration=1100,loop=0,optimize=True); print('gif',len(frames))
+async def main():
+ if 'gif' not in sys.argv: await shots()
+ if 'shots' not in sys.argv: await gif()
+asyncio.run(main())
diff --git a/scripts/demo/router.php b/scripts/demo/router.php
new file mode 100644
index 0000000..e7d05d7
--- /dev/null
+++ b/scripts/demo/router.php
@@ -0,0 +1,6 @@
+