# 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-: ;\n ... --udt-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/`, `POST /api//`, `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/ -> REGISTRY.state(name)`, `POST /api//` (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/`. - Produces: a page whose shell imports `./modules/.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`, `
`, `