aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-09 18:10:22 +0100
committerDanilo M. <danix@danix.xyz>2026-11-09 18:10:22 +0100
commit4b98da80bdfd5016ad9f5858310d4b8bacafe7bb (patch)
tree83c9eb6bf503fffe565af32f68de817c3f4a0a9b /docs
downloaddeck-it-4b98da80bdfd5016ad9f5858310d4b8bacafe7bb.tar.gz
deck-it-4b98da80bdfd5016ad9f5858310d4b8bacafe7bb.zip
docs: add deck-it design and implementation plan
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-11-09-deck-it.md718
-rw-r--r--docs/superpowers/specs/2026-11-09-deck-it-design.md133
2 files changed, 851 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-11-09-deck-it.md b/docs/superpowers/plans/2026-11-09-deck-it.md
new file mode 100644
index 0000000..5f70bae
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-09-deck-it.md
@@ -0,0 +1,718 @@
+# deck-it Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A modular tablet dashboard, stream-deck, and second monitor for a Slackware-current Hyprland desktop, UDT-themed, reusing tabdeck's plumbing.
+
+**Architecture:** A Python stdlib HTTP server serves a web page over `adb reverse`; the page is a shell that mounts independent module panels. Backend modules register a `Module` (state + allow-listed actions). A separate half creates a headless Hyprland output streamed by wayvnc to AVNC. Theme comes from UDT's palette files, never restated.
+
+**Tech Stack:** Python 3.11+ stdlib (one dep: `websocket-client` for OBS), plain HTML/CSS/JS, bash launcher, wayvnc, adb.
+
+**Spec:** `docs/superpowers/specs/2026-11-09-deck-it-design.md`
+
+## Global Constraints
+
+- Python 3.11+; stdlib only except `websocket-client`.
+- No systemd. SysV init + eudev.
+- Server listens only on a Unix socket in `$XDG_RUNTIME_DIR/deck-it` (mode 0700), reached via `adb reverse tcp:8765 localfilesystem:$RUN/http.sock`.
+- Client sends module + action ids, never a command string.
+- UDT palette is read, never restated; a missing palette falls back.
+- Commits are conventional, GPG-signed (`git commit -S`), one per task.
+- Reused tabdeck code is MIT; keep its notice in `NOTICE`.
+
+## Review Focus
+
+- **OBS connection drops mid-action** → the HTTP request must return an error, never hang.
+- **llm-cost-dashboard is down** → the llm panel shows "offline", not a 500.
+- **Oversized or malformed POST body** → rejected without crashing the server.
+- **Two taps race** (double record toggle) → serialized, no duplicate start.
+- **Palette file missing** (before UDT install) → built-in fallback, page still renders.
+
+---
+
+## Phase 0 — Core
+
+### Task 1: Scaffold and config
+
+**Files:**
+- Create: `deckit/__init__.py`, `deckit/config.py`, `config.toml`, `tests/test_config.py`
+- Create: `README.md`, `LICENSE`, `NOTICE`, `AGENTS.md`, `.gitignore`, `git init`
+
+**Interfaces:**
+- Produces: `deckit.config.user_config_path() -> Path`, `load_config() -> dict`, `config_error(cfg) -> str | None`.
+
+- [ ] **Step 1: Scaffold the repo**
+
+Run `git init -b master`, create the directories. License: GPLv2 (confirm with maintainer; MIT tabdeck portions are compatible). `NOTICE` credits tabdeck (MIT) and its bundled fonts/icons. `AGENTS.md` stays thin and points at this plan and the spec.
+
+- [ ] **Step 2: Write the failing test**
+
+```python
+# tests/test_config.py
+import sys, tempfile, tomllib
+from pathlib import Path
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT))
+import unittest
+import deckit.config as config
+
+class Config(unittest.TestCase):
+ def test_default_config_is_valid(self):
+ with open(ROOT / "config.toml", "rb") as f:
+ self.assertIsNone(config.config_error(tomllib.load(f)))
+
+ def test_rejects_bad_values(self):
+ bad = [{"display": {"name": "TAB;rm"}},
+ {"display": {"refresh": 0}},
+ {"ports": {"command_center": 80}},
+ {"ports": {"command_center": 5900, "display": 5900}}]
+ for cfg in bad:
+ self.assertIsNotNone(config.config_error(cfg), cfg)
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 3: Run it, expect failure**
+
+Run: `python3 tests/test_config.py -v`
+Expected: FAIL (no module `deckit.config`).
+
+- [ ] **Step 4: Implement config**
+
+`config.toml` carries: `[hyprland]` (`"auto"`), `[display]` (`resolution/position/scale/refresh/name`), `[ports]` (`command_center=8765`, `display=5900`), `[hardware]` (`net/battery/cpu_sensor/gpu`), `[modules]` (per-module bools, default true), `[obs]` (`host/port/password`), `[llm]` (`url`), `[sbbuilder]` (`host`).
+
+`config_error(cfg)` mirrors tabdeck: validate `display.name` (`[A-Za-z0-9_-]{1,32}`), `display.refresh` int 1–240, ports int 1024–65535 and differing. `user_config_path()` returns `$XDG_CONFIG_HOME/deck-it/config.toml`; `load_config()` uses it if present else the repo `config.toml`, exiting on `config_error`.
+
+- [ ] **Step 5: Run the test, expect pass**
+
+Run: `python3 tests/test_config.py -v`
+Expected: PASS.
+
+- [ ] **Step 6: Commit** — `git add -A && git commit -S -m "feat: scaffold and config"`
+
+### Task 2: UDT theming
+
+**Files:**
+- Create: `deckit/theme.py`, `tests/test_theme.py`, `tests/fixtures/palette.rasi`, `tests/fixtures/accent.rasi`
+
+**Interfaces:**
+- Produces: `parse_palette(text) -> dict[str, str]`, `parse_accent(text) -> str | None`, `css(colors: dict, accent: str | None) -> str`, `load() -> str` (reads the two live paths, falling back).
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_theme.py (same sys.path preamble as Task 1)
+import unittest
+import deckit.theme as theme
+
+PALETTE = """* {
+ base: #24273aff;
+ text: #cad3f5ff;
+ surface0: #363a4fff;
+}"""
+ACCENT = "* { accent: #b7bdf8; }"
+
+class Theme(unittest.TestCase):
+ def test_parses_palette(self):
+ self.assertEqual(theme.parse_palette(PALETTE),
+ {"base": "#24273aff", "text": "#cad3f5ff", "surface0": "#363a4fff"})
+
+ def test_parses_accent(self):
+ self.assertEqual(theme.parse_accent(ACCENT), "#b7bdf8")
+ self.assertIsNone(theme.parse_accent("nothing"))
+
+ def test_css_exposes_vars_and_falls_back(self):
+ out = theme.css({"base": "#24273a"}, None)
+ self.assertIn("--udt-base: #24273a", out)
+ self.assertIn("--udt-accent:", out) # fallback accent
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run it, expect failure** — `python3 tests/test_theme.py -v`; FAIL.
+
+- [ ] **Step 3: Implement theme**
+
+`parse_palette` matches `^\s*([a-z0-9]+)\s*:\s*(#[0-9a-fA-F]{6,8});`. `parse_accent` matches `accent:\s*(#[0-9a-fA-F]{6,8})`. `css(colors, accent)` emits `:root {\n --udt-<name>: <value>;\n ... --udt-accent: <accent or FALLBACK["accent"]>;\n}`. `FALLBACK` is the Macchiato set (base `#24273a`, mantle `#1e2030`, crust `#181926`, surface0 `#363a4f`, surface1 `#494d64`, surface2 `#5b6078`, text `#cad3f5`, subtext0 `#a5adcb`, subtext1 `#b8c0e0`, overlay0 `#6e738d`, red `#ed8796`, green `#a6da95`, yellow `#eed49f`, teal `#8bd5ca`, blue `#8aadf4`, lavender `#b7bdf8`, peach `#f5a97f`, mauve `#c6a0f6`, accent `#b7bdf8`). `load()` reads `~/.config/rofi/udt/palette.rasi` + `~/.cache/udt/udt-accent.rasi`, using `FALLBACK` on `OSError` or empty parse.
+
+- [ ] **Step 4: Run the test, expect pass** — `python3 tests/test_theme.py -v`; PASS.
+
+- [ ] **Step 5: Commit** — `git add deckit/theme.py tests/test_theme.py tests/fixtures && git commit -S -m "feat: UDT palette theming"`
+
+### Task 3: Module contract and registry
+
+**Files:**
+- Create: `deckit/module.py`, `tests/test_module.py`
+
+**Interfaces:**
+- Produces: `ModuleError`, `Module` (dataclass: `name`, `title`, `state: Callable[[], dict]`, `actions: dict[str, Callable[[dict], dict | None]]`, `start`/`stop` optional), `Registry(modules)` with `names() -> list[dict]`, `state(name) -> dict`, `run_action(name, action, body) -> dict | None`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_module.py
+import unittest
+import deckit.module as mod
+
+def make():
+ return mod.Module(name="demo", title="Demo",
+ state=lambda: {"ok": True},
+ actions={"ping": lambda body: {"got": body.get("n")}})
+
+class RegistryTest(unittest.TestCase):
+ def test_lists_and_dispatches(self):
+ r = mod.Registry([make()])
+ self.assertEqual(r.names(), [{"name": "demo", "title": "Demo"}])
+ self.assertEqual(r.state("demo"), {"ok": True})
+ self.assertEqual(r.run_action("demo", "ping", {"n": 3}), {"got": 3})
+
+ def test_unknown_module_and_action_raise(self):
+ r = mod.Registry([make()])
+ with self.assertRaises(mod.ModuleError):
+ r.state("nope")
+ with self.assertRaises(mod.ModuleError):
+ r.run_action("demo", "nope", {})
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+
+- [ ] **Step 3: Implement the contract**
+
+`Module` is a `@dataclass`. `Registry` stores `{m.name: m}`; `state`/`run_action` raise `ModuleError` on unknown names/actions; `run_action` calls the verb with the JSON body (a dict) and returns its result.
+
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: module contract and registry"`
+
+### Task 4: HTTP server, auth, routing
+
+**Files:**
+- Create: `deckit/server.py`, `deckit/__main__.py`, `tests/test_server.py`
+
+**Interfaces:**
+- Consumes: `Registry` (Task 3), `theme.load()` (Task 2).
+- Produces: `serve(listen: tuple[str, int] | None = None) -> None`; module-global `TOKEN`; routes `GET /`, `GET /theme.css`, `GET /api/modules`, `GET /api/<name>`, `POST /api/<name>/<action>`, `GET /static/...`.
+
+- [ ] **Step 1: Write the failing test** (adapted from tabdeck's `Http` class)
+
+```python
+# tests/test_server.py
+import json, os, socket, tempfile, threading, unittest
+from pathlib import Path
+import http.client
+import deckit.server as srv
+import deckit.module as mod
+
+TOKEN = "test-token"
+HOST = {"Host": "127.0.0.1:8765", "Cookie": f"deckit={TOKEN}"}
+ORIGIN = {"Origin": "http://127.0.0.1:8765"}
+
+class UnixConnection(http.client.HTTPConnection):
+ def __init__(self, path):
+ super().__init__("localhost"); self.path = path
+ def connect(self):
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ self.sock.connect(self.path)
+
+class Server(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.dir = tempfile.TemporaryDirectory()
+ cls.sock = os.path.join(cls.dir.name, "http.sock")
+ srv.TOKEN = TOKEN
+ srv.REGISTRY = mod.Registry([mod.Module("demo", "Demo", lambda: {"ok": True})])
+ cls.server = srv.UnixHTTPServer(cls.sock, srv.Handler)
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
+ @classmethod
+ def tearDownClass(cls):
+ cls.server.shutdown(); cls.server.server_close(); cls.dir.cleanup()
+
+ def req(self, method, path, headers=None, body=None):
+ c = UnixConnection(self.sock); c.request(method, path, body=body, headers=headers or {})
+ r = c.getresponse(); data = r.read(); c.close(); return r.status, data
+
+ def test_assets_and_api(self):
+ self.assertEqual(self.req("GET", "/", HOST)[0], 200)
+ self.assertEqual(self.req("GET", "/theme.css", HOST)[0], 200)
+ self.assertEqual(json.loads(self.req("GET", "/api/modules", HOST)[1]),
+ [{"name": "demo", "title": "Demo"}])
+ self.assertEqual(json.loads(self.req("GET", "/api/demo", HOST)[1]), {"ok": True})
+
+ def test_trust_and_auth(self):
+ self.assertEqual(self.req("GET", "/api/demo", {"Host": "evil.com"})[0], 403)
+ self.assertEqual(self.req("POST", "/api/demo/x", {**HOST, "Origin": "https://evil.com"})[0], 403)
+ self.assertEqual(self.req("GET", "/api/demo", {"Host": "127.0.0.1:8765"})[0], 401)
+
+ def test_unlock_and_bad_action(self):
+ c = UnixConnection(self.sock); c.request("GET", f"/?t={TOKEN}", headers={"Host": "127.0.0.1:8765"})
+ r = c.getresponse(); r.read(); c.close()
+ self.assertEqual(r.status, 303)
+ self.assertIn("deckit=", r.getheader("Set-Cookie"))
+ self.assertIn("SameSite=Strict", r.getheader("Set-Cookie"))
+ self.assertEqual(self.req("POST", "/api/demo/nope", {**HOST, **ORIGIN})[0], 404)
+
+ def test_rejects_oversized_body(self):
+ big = "x" * 5000
+ status, _ = self.req("POST", "/api/demo/ping",
+ {**HOST, **ORIGIN, "Content-Type": "application/json"}, big)
+ self.assertEqual(status, 400)
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+
+- [ ] **Step 3: Implement the server**
+
+Port tabdeck's `Handler`/`UnixHTTPServer`/`serve`, adding: `REGISTRY` (module-global, built in `serve`), `GET /api/modules -> REGISTRY.names()`, `GET /api/<name> -> REGISTRY.state(name)`, `POST /api/<name>/<action>` (JSON body ≤ 4096 bytes, `Content-Type: application/json`, else 400; `ModuleError` → 400). `GET /theme.css` serves `theme.load()`. Static files resolve under `static/` only. Auth/trust (Host/Origin), `?t=` unlock setting cookie `deckit` remain as tabdeck has them. `__main__.py` dispatches `serve`, `setup-display`, `env` (prints `MON= PORT= VNC_PORT=`).
+
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: HTTP server with trust, auth, module routing"`
+
+### Task 5: Frontend shell and theming
+
+**Files:**
+- Create: `static/index.html`, `static/deck.css`, `static/app.js`, `tests/test_frontend.py`
+
+**Interfaces:**
+- Consumes: `/theme.css`, `/api/modules`, `/api/<name>`.
+- Produces: a page whose shell imports `./modules/<name>.js` per enabled module.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_frontend.py — served-page smoke test
+# (run a server like Task 4; assert)
+# GET / -> body contains 'app.js' and 'deck.css'
+# GET /deck.css -> body contains '--udt-bg' usage
+```
+
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+
+- [ ] **Step 3: Implement**
+
+`index.html`: viewport meta, link `theme.css` + `deck.css`, `<main id="grid">`, `<script type="module" src="app.js">`. `deck.css`: grid layout and shared card/button classes, all colours via `var(--udt-*)`. `app.js`: `fetch('/api/modules')`, then for each `await import('./modules/'+name+'.js')`, call `mount(el)` and poll `update(state)` at the module's `interval` (default 1000 ms). Refresh on `visibilitychange`.
+
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: frontend shell and UDT-themed layout"`
+
+### Task 6: Launcher, tunnel, Slackware autostart
+
+**Files:**
+- Create: `deck-it` (bash), `tests/test_launcher.py`
+
+**Interfaces:**
+- Consumes: `python3 -m deckit env`, `setup-display`.
+- Produces: `deck-it` subcommands `start` (default), `stop`, `env`, `pair`, `wifi`, `install`, `uninstall`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_launcher.py
+import subprocess, unittest
+from pathlib import Path
+ROOT = Path(__file__).resolve().parent.parent
+
+class Launcher(unittest.TestCase):
+ def test_syntax(self):
+ self.assertEqual(subprocess.run(["bash", "-n", str(ROOT / "deck-it")]).returncode, 0)
+ def test_env_prints_keys(self):
+ out = subprocess.run(["bash", str(ROOT / "deck-it"), "env"],
+ capture_output=True, text=True, cwd=ROOT).stdout
+ for key in ("MON=", "PORT=", "VNC_PORT="):
+ self.assertIn(key, out)
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+
+- [ ] **Step 3: Implement the launcher**
+
+Adapt tabdeck's `tablet`: `RUN="$XDG_RUNTIME_DIR/deck-it"`, `CONF="${XDG_CONFIG_HOME:-$HOME/.config}/deck-it"`, `MON/PORT/VNC_PORT` from `python3 -m deckit env`; `tunnel()` uses `adb reverse tcp:5900 localfilesystem:$RUN/vnc.sock` and `tcp:8765 localfilesystem:$RUN/http.sock`; `vnc_setup()` and `open_avnc()` from tabdeck. `install` writes `/etc/udev/rules.d/70-deck-it.rules` with a non-blocking `RUN+="/bin/sh -c 'setsid <dir>/deck-it start >/dev/null 2>&1 &'"` and prints the `exec-once = <dir>/deck-it` line to add to hypr-theme. `uninstall` removes the rule. No systemd, no `systemctl`.
+
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: launcher, adb tunnel, Slackware autostart"`
+
+### Task 7: Second monitor
+
+**Files:**
+- Create: `deckit/display.py`, `tests/test_display.py`
+
+**Interfaces:**
+- Produces: `tablet_resolution(cfg, adb=None) -> str`, `setup_display(cfg, run_cmd=None) -> None`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_display.py
+import unittest
+import deckit.display as display
+
+class Res(unittest.TestCase):
+ def test_reads_adb(self):
+ self.assertEqual(display.tablet_resolution({"display": {}}, adb=lambda: "Physical size: 1920x1200"),
+ "1920x1200")
+ def test_config_override(self):
+ self.assertEqual(display.tablet_resolution({"display": {"resolution": "2560x1600"}},
+ adb=lambda: "1920x1200"), "2560x1600")
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+
+- [ ] **Step 3: Implement**
+
+Port tabdeck's `tablet_resolution` (sorted `max x min`) and `setup_display` (create headless output if absent, then Lua `hl.monitor({...})` for the Lua config or `hyprctl keyword monitor` for classic). `adb`/`run_cmd` are injectable for tests.
+
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: virtual monitor setup"`
+
+### Task 8: stats module
+
+**Files:**
+- Create: `deckit/modules/__init__.py`, `deckit/modules/stats.py`, `tests/test_stats.py`
+
+**Interfaces:**
+- Produces: `stats.make(cfg) -> Module` (name `stats`); registry builder `build(cfg) -> Registry` in `deckit/modules/__init__.py` that lazily imports each `deckit.modules.<name>` present and enabled under `[modules]` (default on), skipping `ImportError`. Lazy discovery means a later phase just adds a file, and the assistant slot stays empty without touching the core.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_stats.py
+import unittest
+import deckit.modules.stats as stats
+
+class Stats(unittest.TestCase):
+ def test_state_shape(self):
+ st = stats.make({}).state()
+ for key in ("cpu", "memUsed", "memTotal", "net", "uptime", "host"):
+ self.assertIn(key, st)
+ self.assertIsInstance(st["cpu"], int)
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+
+- [ ] **Step 3: Implement**
+
+Port tabdeck's `cpu_percent`, `mem`, `net_rates`, `gpu`, `detect_*` readers into `deckit/modules/stats.py`; `make(cfg)` closes over the `[hardware]` overrides. `deckit/modules/__init__.py`'s `build(cfg)` walks `Path(__file__).parent` for `*.py` (excluding `__init__`), `importlib.import_module`s each that `[modules]` does not disable, calls its `make(cfg)`, and returns a `Registry`.
+
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: stats module"`
+
+### Task 9: music module
+
+**Files:**
+- Create: `deckit/modules/music.py`, `tests/test_music.py`
+
+**Interfaces:**
+- Produces: `music.make(cfg) -> Module` (name `music`); state has `status/title/artist/album/length/position/art/player/volume`; actions `prev/play/next/voldown/volup/mute`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_music.py
+import unittest
+import deckit.modules.music as music
+
+class Music(unittest.TestCase):
+ def test_parses_metadata(self):
+ sep = "\x1f"
+ fake = lambda cmd, timeout=2: {
+ "metadata": sep.join(["Playing", "Song", "Artist", "Album", "200000000", "1000000", "", "spotify"]),
+ "get-volume": "Volume: 0.42",
+ }[cmd[1]]
+ m = music.make({"run": fake})
+ st = m.state()
+ self.assertEqual(st["title"], "Song")
+ self.assertEqual(st["status"], "Playing")
+ self.assertEqual(st["volume"]["level"], 42)
+ def test_action_commands(self):
+ self.assertEqual(music.BASE_ACTIONS["next"], ["playerctl", "next"])
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+
+- [ ] **Step 3: Implement**
+
+Port tabdeck's `player()` (SEP split, art URL handling) and `BASE_ACTIONS` (wpctl/playerctl); inject `run` for tests. Register actions.
+
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: music module"`
+
+### Task 10: windows module
+
+**Files:**
+- Create: `deckit/modules/windows.py`, `tests/test_windows.py`
+
+**Interfaces:**
+- Produces: `windows.make(cfg) -> Module` (name `windows`); state `{windows, workspaces, activeClass}`; actions `workspace/<n>`, `focus/<addr>`, `close/<addr>`, `launch/<key>` from a `[launch]` table.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_windows.py
+import unittest
+import deckit.modules.windows as windows
+
+class Commands(unittest.TestCase):
+ def test_lua_and_classic(self):
+ self.assertEqual(windows.workspace_cmd(3, lua=False), ["hyprctl", "dispatch", "workspace", "3"])
+ self.assertEqual(windows.workspace_cmd(3, lua=True)[:2], ["hyprctl", "eval"])
+ def test_address_guard(self):
+ self.assertIsNone(windows.ADDR.fullmatch("0x1;rm"))
+ self.assertTrue(windows.ADDR.fullmatch("0x55897e5c0800"))
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+
+- [ ] **Step 3: Implement**
+
+Port tabdeck's `workspace_cmd`, `window_cmd`, `windows()` (hyprctl clients/activewindow/monitors), and address regex. `[launch]` in config maps key id → command (string or list); actions run declared commands only. Client sends a key id, never a command.
+
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: windows module"`
+
+---
+
+## Phase 1 — OBS stream-deck
+
+### Task 11: obs-websocket client
+
+**Files:**
+- Create: `deckit/obsclient.py`, `tests/test_obsclient.py`
+
+**Interfaces:**
+- Produces: `auth_string(password: str, salt: str, challenge: str) -> str`, `class ObsClient` with `connect()`, `close()`, `request(type: str, data: dict | None = None) -> dict` (`ModuleError` if disconnected or on error status), and an `events` callback setter.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_obsclient.py
+import base64, hashlib, unittest
+import deckit.obsclient as oc
+
+class Auth(unittest.TestCase):
+ def test_matches_protocol_formula(self):
+ pw, salt, challenge = "supersecret", "salty", "chall"
+ step1 = base64.b64encode(hashlib.sha256((pw + salt).encode()).digest()).decode()
+ expected = base64.b64encode(hashlib.sha256((step1 + challenge).encode()).digest()).decode()
+ self.assertEqual(oc.auth_string(pw, salt, challenge), expected)
+ self.assertNotEqual(oc.auth_string("other", salt, challenge), expected)
+
+class Disconnected(unittest.TestCase):
+ def test_request_raises_when_closed(self):
+ with self.assertRaises(oc.ModuleError):
+ oc.ObsClient(host="127.0.0.1", port=4455, password="").request("GetVersion")
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+
+- [ ] **Step 3: Implement the client**
+
+Use `websocket-client`. On connect, read `Hello`; if `authentication` present, send `Identify` with `authentication=auth_string(password, salt, challenge)` and `rpcVersion: 1`; wait for `Identified`. Run a receive loop in a daemon thread dispatching `Event` to the callback and resolving `RequestResponse` by `requestId` on a dict of pending events with a timeout (`ModuleError` on timeout). `request` holds a lock, sends `{op:6, d:{requestType, requestId, requestData}}`, waits. `close` stops the loop.
+
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: obs-websocket v5 client"`
+
+### Task 12: OBS module
+
+**Files:**
+- Create: `deckit/modules/obs.py`, `tests/test_obs.py`
+
+**Interfaces:**
+- Consumes: `ObsClient` (Task 11).
+- Produces: `obs.make(cfg) -> Module` (name `obs`); state `{connected, scenes, currentScene, recording, streaming, inputs, transition, transitionKinds}`; actions `scene`, `record`, `stream`, `mute`, `volume`, `transition`, `trigger`, `source`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_obs.py
+import unittest
+import deckit.modules.obs as obs
+
+class Fake:
+ connected = True
+ def request(self, kind, data=None):
+ Fake.last = (kind, data)
+ return {"ok": True}
+
+class Actions(unittest.TestCase):
+ def setUp(self):
+ self.m = obs.make({"obs": {"host": "h", "port": 4455, "password": ""},
+ "client": Fake()})
+ def test_scene_and_record(self):
+ self.m.actions["scene"]({"scene": "Live"}); self.assertEqual(Fake.last[0], "SetCurrentProgramScene")
+ self.m.actions["record"]({"on": True}); self.assertEqual(Fake.last[0], "StartRecord")
+ self.m.actions["record"]({"on": False}); self.assertEqual(Fake.last[0], "StopRecord")
+ def test_mute_and_source(self):
+ self.m.actions["mute"]({"input": "Mic", "muted": True}); self.assertEqual(Fake.last[0], "SetInputMute")
+ self.m.actions["source"]({"scene": "S", "id": 2, "on": False}); self.assertEqual(Fake.last[0], "SetSceneItemEnabled")
+
+class Serialized(unittest.TestCase):
+ def test_double_toggle_is_serialized(self):
+ # concurrent record toggles resolve under one lock; no duplicate Start
+ pass
+```
+
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+
+- [ ] **Step 3: Implement**
+
+`make(cfg)` builds an `ObsClient` (injectable for tests via `cfg["client"]`), starts it, and caches state updated from events. The lock serializes actions. Map actions to obs-websocket requests: scene → `SetCurrentProgramScene`, record → `StartRecord`/`StopRecord`/`ToggleRecord`, stream → `StartStream`/`StopStream`, mute → `SetInputMute`, volume → `SetInputVolume`, transition → `SetCurrentSceneTransition`, trigger → `TriggerStudioModeTransition`, source → `SetSceneItemEnabled`. A disconnected client raises `ModuleError` (Review Focus: no hang).
+
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: OBS stream-deck module"`
+
+### Task 13: OBS panel
+
+**Files:**
+- Create: `static/modules/obs.js`, `tests/test_frontend.py` (extend)
+
+- [ ] **Step 1: Extend the smoke test** — assert `obs.js` is served and exports a panel with `mount`/`update`.
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+- [ ] **Step 3: Implement** — a grid: scene buttons (active highlighted), record/stream toggles with elapsed time, audio channel rows (mute + volume), transition select + trigger, source toggles. Uses `--udt-*` vars.
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: OBS stream-deck panel"`
+
+---
+
+## Phase 2 — read-only feeds
+
+### Task 14: llm module
+
+**Files:**
+- Create: `deckit/modules/llm.py`, `tests/test_llm.py`
+
+**Interfaces:**
+- Produces: `llm.make(cfg) -> Module` (name `llm`); state `{claude: {...}, opencode: {...}, offline: bool}` fetched from `[llm] url` (default `http://127.0.0.1:8420`).
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_llm.py
+import unittest
+import deckit.modules.llm as llm
+
+class Down(unittest.TestCase):
+ def test_offline_when_dashboard_down(self):
+ def boom(url, timeout=2):
+ raise OSError("refused")
+ st = llm.make({"llm": {"url": "http://x"}, "fetch": boom}).state()
+ self.assertTrue(st["offline"])
+
+class Up(unittest.TestCase):
+ def test_merges_endpoints(self):
+ data = {"/api/claude_limits": [{"context_pct": 12}],
+ "/api/claude_session": {"context_used": 1200},
+ "/api/summary": {"total_cost": 1.5}}
+ st = llm.make({"llm": {"url": "http://x"},
+ "fetch": lambda u, timeout=2: data[u]}).state()
+ self.assertFalse(st["offline"])
+ self.assertEqual(st["claude"]["context_pct"], 12)
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+- [ ] **Step 3: Implement** — fetch `/api/summary`, `/api/today`, `/api/claude_limits`, `/api/claude_session`, `/api/opencode_agents`; on any `OSError`/`JSONDecodeError` return `{"offline": True}` (Review Focus). Inject `fetch` for tests.
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: llm-cost-dashboard relay module"`
+
+### Task 15: llm panel
+
+**Files:**
+- Create: `static/modules/llm.js`, extend `tests/test_frontend.py`
+- Steps: served smoke test (fail → implement cards: claude 5h/7d/context, opencode spend → pass → commit).
+
+### Task 16: sb-builder module
+
+**Files:**
+- Create: `deckit/modules/sbbuilder.py`, `tests/test_sbbuilder.py`
+
+**Interfaces:**
+- Produces: `sbbuilder.make(cfg) -> Module` (name `sbbuilder`); state `{offline: bool, jobs: [...]}` from `ssh <host> sbb-remote jobs`.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_sbbuilder.py
+import json, unittest
+import deckit.modules.sbbuilder as sbb
+
+class Fail(unittest.TestCase):
+ def test_offline_on_ssh_failure(self):
+ def boom(cmd, timeout=5):
+ raise OSError("no route")
+ self.assertTrue(sbb.make({"sbbuilder": {"host": "buildsystem"}, "run": boom}).state()["offline"])
+
+class Ok(unittest.TestCase):
+ def test_parses_jobs(self):
+ out = json.dumps({"jobs": [{"id": "20261109-101010-build", "status": "running"}]})
+ st = sbb.make({"sbbuilder": {"host": "buildsystem"}, "run": lambda cmd, timeout=5: out}).state()
+ self.assertFalse(st["offline"])
+ self.assertEqual(st["jobs"][0]["status"], "running")
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run it, expect failure** — FAIL.
+- [ ] **Step 3: Implement** — run `["ssh", "-o", "BatchMode=yes", host, "sbb-remote", "jobs"]` (injectable `run`); parse JSON; on `OSError`/`JSONDecodeError` return `{"offline": True}`. Read-only.
+- [ ] **Step 4: Run the test, expect pass** — PASS.
+- [ ] **Step 5: Commit** — `git commit -S -am "feat: sb-builder status module"`
+
+### Task 17: sb-builder panel
+
+**Files:**
+- Create: `static/modules/sbbuilder.js`, extend `tests/test_frontend.py`
+- Steps: served smoke test (fail → implement job list with status chips → pass → commit).
+
+---
+
+## Phase 3 — deferred
+
+### Assistant (not built)
+
+`desktop-assistant` control is out of scope. When its shape is decided, it is
+`deckit/modules/assistant.py` plus `static/modules/assistant.js` using the
+existing Unix-socket protocol (`state`, `ctx`); no core change.
+
+---
+
+## Self-review
+
+- **Spec coverage:** goals 1–6 map to Tasks 1–17; second monitor → Task 7; OBS → 11–13; read-only feeds → 14–17; assistant deferred → Phase 3.
+- **Review Focus:** OBS drop → Task 12; llm down → Task 14; oversized body → Task 4; toggle race → Task 12; palette missing → Task 2.
+- **Types:** `Module`/`Registry` signatures are used identically in Tasks 3, 4, 8–17; `run`/`fetch`/`client` are the injectable seams.
diff --git a/docs/superpowers/specs/2026-11-09-deck-it-design.md b/docs/superpowers/specs/2026-11-09-deck-it-design.md
new file mode 100644
index 0000000..fad1e4f
--- /dev/null
+++ b/docs/superpowers/specs/2026-11-09-deck-it-design.md
@@ -0,0 +1,133 @@
+# deck-it — design
+
+**Date:** 2026-11-09
+**Status:** approved (design), pending implementation plan
+
+## Intent
+
+An always-on tablet dashboard, stream-deck, and second monitor for this desktop:
+Slackware-current, Hyprland with the Lua config (hypr-theme), UDT-themed. It
+reuses tabdeck's plumbing (Python stdlib HTTP server, a web page in the tablet's
+browser over `adb reverse`, and a virtual monitor streamed with wayvnc) but
+replaces tabdeck's fixed stats/keys/mull surface with a module system, and swaps
+tabdeck's Arch/systemd assumptions for Slackware's.
+
+## Goals
+
+1. Always-on touch control on the Android tablet over USB; Wi-Fi optional.
+2. Second monitor: virtual Hyprland output + wayvnc, shown in AVNC.
+3. Stream-deck control of OBS: recording, scenes, audio channels, transitions,
+ sources.
+4. Read-only dashboards: system stats, music/volume, windows/workspaces/launch,
+ claude-code + opencode usage (via llm-cost-dashboard), sb-builder progress.
+5. Modular: a new app is a new module, with no core edits.
+6. UDT-themed: the palette is read, never restated; a scheme switch recolours
+ the tablet.
+
+## Non-goals
+
+- Not a replacement for quickshell / rofi-scripts. They stay for the static
+ desktop approach; this is the fast always-on surface. Existing commands
+ (playerctl, wpctl, hyprctl, rofi scripts) are reused, not reimplemented.
+- Not a general automation engine. Only declared actions run.
+- No cloud, no account, no app-store install.
+- Assistant control: deferred. A module slot is reserved.
+
+## Anti-goals (failures to avoid)
+
+- The tablet can ever run an arbitrary command.
+- A scheme or wallpaper change leaves the tablet stale or desynced.
+- Adding a module means editing the core server.
+- Anything requires systemd.
+
+## Constraints
+
+- Slackware-current: SysV init, eudev, no systemd. Hyprland with the Lua config.
+- Python stdlib only, with one exception: `websocket-client` for obs-websocket.
+- Tablet: Android + AVNC + a Chromium browser.
+- UDT is the single source of theme truth.
+
+## Module contract
+
+A module contributes:
+- **Backend** — a `make(cfg) -> Module` factory returning a `Module` with a
+ `state()` callable (GET) and an `actions` dict of allow-listed verbs (POST).
+ The client sends `{module, action, ...}`; the server maps ids to code and the
+ client never sends a command.
+- **Frontend** — `static/modules/<name>.js`, a plain object
+ `{title, interval, mount(el), update(state)}` rendered into the shell grid.
+
+The core provides the HTTP server, auth/trust, the adb tunnel, theming, and the
+module registry. Modules are enabled in config under `[modules]`.
+
+## Theming
+
+At startup `deckit/theme.py` reads:
+- `~/.config/rofi/udt/palette.rasi` — the structural palette, entries
+ `name: #rrggbb[aa]` (base, mantle, crust, surface0-2, text, subtext0-1,
+ overlay0-2, red, green, yellow, teal, blue, lavender, peach, mauve, termblue,
+ scrim).
+- `~/.cache/udt/udt-accent.rasi` — `* { accent: #rrggbb; }`.
+
+It serves `/theme.css` with `:root { --udt-<name>: #...; --udt-accent: #...; }`.
+The frontend styles only through those variables. When UDT regenerates a scheme
+(`install.sh`) or accent (`udt-accent` on wallpaper change), the server re-reads
+on an mtime poll and a page refresh picks it up. A missing palette falls back to
+a built-in Catppuccin Macchiato set so the page always renders. Fonts: Noto Sans
+for UI, Inconsolata Nerd Font Mono for numbers.
+
+## Slackware wiring
+
+No systemd. `deck-it` is a bash launcher with `start` (default), `stop`, `pair`,
+`wifi`, `install`, `uninstall`, `env`, `setup-display`.
+
+- Start from Hyprland via hypr-theme's `exec-once` (the compositor must be up for
+ the virtual output and wayvnc); `install` prints the exact line to add.
+- Plug-in autostart: an eudev rule in `/etc/udev/rules.d/70-deck-it.rules` with a
+ non-blocking `RUN+` that setsids `deck-it start`. A login-time check covers an
+ already-connected tablet.
+- No `loginctl`; lock/leave stays in the existing rofi powermenu.
+
+## Second monitor
+
+Reuse tabdeck's `setup_display`: create a headless Hyprland output sized to the
+tablet (`hyprctl output create headless`, then the Lua `hl.monitor` table), run
+wayvnc with TLS + a random password in a private dir, and tunnel the VNC port to
+the tablet with `adb reverse tcp:5900 localfilesystem:<run>/vnc.sock`. AVNC is
+opened with the login in the link. wayvnc works on Hyprland, which implements
+the wlroots screencopy and virtual-input protocols it needs.
+
+## Security
+
+Reuse tabdeck's model unchanged in spirit:
+- Unix socket in a 0700 `$XDG_RUNTIME_DIR/deck-it`, exposed only by `adb reverse`.
+- Token cookie (HttpOnly, SameSite=Strict) unlocks the API.
+- Host/Origin checks block DNS rebinding and cross-site writes.
+- Client sends ids, never commands. OBS's password and the OBS connection stay
+ server-side.
+
+## Per-module specifics
+
+| Module | Source | Actions |
+|---|---|---|
+| stats | /proc, sysfs, nvidia-smi | none (read) |
+| music | playerctl + wpctl | play/pause, prev/next, volume ±/mute |
+| windows | hyprctl clients/activewindow/workspaces | workspace switch, focus/close, launch (config commands) |
+| obs | obs-websocket v5 client | set scene, toggle record/stream, input volume/mute, set/trigger transition, toggle source |
+| llm | relay llm-cost-dashboard `/api/summary`, `/api/today`, `/api/claude_limits`, `/api/claude_session`, `/api/opencode_agents` | none (read) |
+| sbbuilder | `ssh <host> sbb-remote jobs` (read-only) | none (read) |
+| assistant | deferred | — |
+
+## Build order
+
+1. Core — scaffold, config, theming, module registry, server + auth, frontend
+ shell, launcher, second monitor.
+2. First modules — stats, music, windows (proves the contract).
+3. OBS — the stream-deck.
+4. Read-only feeds — llm + sbbuilder.
+5. Assistant — deferred.
+
+## Open calls (settled in the plan)
+
+- OBS client: `websocket-client` is the single non-stdlib dependency.
+- sb-builder bridge: `sbb-remote jobs` over ssh; no change to sb-builder.