From 4b98da80bdfd5016ad9f5858310d4b8bacafe7bb Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 9 Nov 2026 18:10:22 +0100 Subject: docs: add deck-it design and implementation plan --- docs/superpowers/plans/2026-11-09-deck-it.md | 718 +++++++++++++++++++++ .../superpowers/specs/2026-11-09-deck-it-design.md | 133 ++++ 2 files changed, 851 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-09-deck-it.md create mode 100644 docs/superpowers/specs/2026-11-09-deck-it-design.md (limited to 'docs/superpowers') 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-: ;\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`, `
`, `