From dc6da5af3424812f9e24cd95c7b2dc8c8f4edef8 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 17 Aug 2026 13:18:21 -0400 Subject: [PATCH] Plan the Panama Control Center --- .../plans/2026-08-17-panama-control-center.md | 657 ++++++++++++++++++ 1 file changed, 657 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-17-panama-control-center.md diff --git a/docs/superpowers/plans/2026-08-17-panama-control-center.md b/docs/superpowers/plans/2026-08-17-panama-control-center.md new file mode 100644 index 0000000..8091be1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-panama-control-center.md @@ -0,0 +1,657 @@ +# Panama Control Center 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:** Expand Panama Quick Settings into the approved Control Center with secure Home Assistant favourites and capability-aware iPhone continuity. + +**Architecture:** Two executable Python helpers own the external boundaries: KDE Connect CLI/D-Bus and the Home Assistant REST API/credential lookup. Quickshell singletons normalize those JSON contracts for focused Home and Phone components inside the existing Quick Settings window; Ongoing and Signal Glass consume transfer lifecycle without duplicating state. + +**Tech Stack:** Python 3.14 standard library, Secret Service CLI, KDE Connect 25.12 CLI/D-Bus, Home Assistant REST API, Quickshell 0.3/QML, Bash contract tests, Python `unittest`, jq, Hyprland 0.56 + +**Spec:** `docs/superpowers/specs/2026-08-17-panama-control-center-design.md` + +## Global Constraints + +- Keep Wi-Fi, Bluetooth, Focus, Caffeine, Night Light, audio, account, settings, power, Escape, and outside-click behavior intact. +- The Control Center width is 430 logical pixels and its top margin is exactly `Theme.barHeight + 2`. +- Only one of Wi-Fi, Bluetooth, sink, source, Home, and Phone may be expanded at once. +- Never print Home Assistant tokens, Authorization headers, clipboard text, certificate fingerprints, IP addresses, response bodies, or full local file paths. +- Home Assistant tokens remain in the gitignored environment file or Secret Service; never write one into tracked content. +- Home Assistant toggle commands accept only entity IDs from the configured favourites snapshot. +- KDE Connect actions derive from advertised plugins; no fake battery or remote-input control is allowed. +- Every Home toggle, file chooser, file send, clipboard send, and Ring action requires a user click. +- Home toggle success, phone Ring, and clipboard success remain globally quiet. +- A running file send appears in Ongoing without a fabricated percentage. +- Automated tests do not toggle a real Home entity, ring the phone, send clipboard contents, or transfer a real file. +- No pulse, shimmer, animated gradient, idle animation, or high-frequency polling. + +--- + +## File map + +### KDE Connect boundary + +- `config/dot/quickshell/scripts/panama-kdeconnect` — validates device/file inputs, normalizes device/plugin status, and invokes allow-listed actions. +- `tests/quickshell/kdeconnect_bridge_test.py` — pure parser, capability, validation, and command tests with fake runners. +- `tests/quickshell/kdeconnect-helper-contract.sh` — live read-only status contract with names and IDs redacted. +- `config/dot/quickshell/services/KdeConnect.qml` — singleton device, action, transfer, fixture, and recent-exchange state. + +### Home Assistant boundary + +- `config/dot/quickshell/scripts/panama-home-assistant` — configuration resolution, normalized REST reads, allow-listed toggles, and URL handoff. +- `tests/quickshell/home_assistant_bridge_test.py` — local fake API and credential/configuration precedence tests. +- `tests/quickshell/home-assistant-helper-contract.sh` — live read-only probe/snapshot contract with entity content redacted. +- `config/dot/quickshell/services/HomeAssistant.qml` — singleton entities, stale state, per-entity action state, fixture, and inline errors. + +### Control Center presentation and integration + +- `config/dot/quickshell/config/Theme.qml` — dedicated `controlCenterWidth` and `controlCenterTopGap` geometry tokens. +- `config/dot/quickshell/modules/quicksettings/QuickSettings.qml` — tighter top attachment and service preparation. +- `config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml` — expanded composition and exclusive section routing. +- `config/dot/quickshell/modules/quicksettings/ControlSectionHeader.qml` — quiet section label and optional trailing action. +- `config/dot/quickshell/modules/quicksettings/HomeControls.qml` — four favourites, expanded configured list, loading/stale/failure states. +- `config/dot/quickshell/modules/quicksettings/HomeTile.qml` — one domain-aware favourite tile. +- `config/dot/quickshell/modules/quicksettings/PhoneControls.qml` — approved compact Continuity Card. +- `config/dot/quickshell/modules/quicksettings/RecentExchange.qml` — one session-local completed transfer row. +- `config/dot/quickshell/modules/bar/StatusCluster.qml` — reachable-phone glyph inside the existing click target. +- `config/dot/quickshell/services/DeviceEvents.qml` — consume shared KDE Connect state instead of a second polling process. +- `config/dot/quickshell/services/Ongoing.qml` — append and control one running phone transfer. +- `config/dot/quickshell/services/StatusEvents.qml` — open a completed received/sent path through the existing allow-listed action. +- `config/dot/quickshell/shell.qml` — diagnostics/fixture IPC for the two services. +- `tests/quickshell/control-center-services-contract.sh` — deterministic service, activity, and event contract. +- `tests/quickshell/control-center-contract.sh` — panel geometry, routing, components, and live layer contract. + +### Documentation + +- `config/dot/hypr/DESKTOP-PARITY.md` — mark both remaining integrations live. +- `config/dot/hypr/README.md` — document Control Center, credential resolution, and iOS capability limits. + +--- + +### Task 1: KDE Connect helper contract + +**Files:** +- Create: `config/dot/quickshell/scripts/panama-kdeconnect` +- Create: `tests/quickshell/kdeconnect_bridge_test.py` +- Create: `tests/quickshell/kdeconnect-helper-contract.sh` + +**Interfaces:** +- Produces: `panama-kdeconnect status` JSON with `available` and `devices`. +- Produces device fields: `id`, `name`, `type`, `paired`, `reachable`, and `actions`. +- Produces: `send-file DEVICE FILE`, `send-clipboard DEVICE`, and `ring DEVICE` with one normalized JSON result. +- Accepts an injectable `runner: Callable[[list[str]], CompletedProcess[str]]` in pure functions. + +- [ ] **Step 1: Write failing parser and action tests** + +Create `tests/quickshell/kdeconnect_bridge_test.py` with import-by-path and these cases: + +```python +class KdeConnectBridgeTest(unittest.TestCase): + def test_status_omits_network_and_exposes_supported_ios_actions(self): + listing = "- Gib's iPhone: 90B54E84548E4F109268EC2D0E7D6930 on 192.168.1.7 via LAN (paired and reachable)\n1 device found\n" + plugins = ["kdeconnect_clipboard", "kdeconnect_findmyphone", "kdeconnect_ping", "kdeconnect_share"] + device = bridge.normalize_device_line(listing.splitlines()[0], plugins) + self.assertEqual(device["name"], "Gib's iPhone") + self.assertEqual(device["actions"], ["clipboard", "ping", "ring", "share"]) + self.assertNotIn("192.168.1.7", json.dumps(device)) + + def test_invalid_device_id_is_rejected(self): + with self.assertRaisesRegex(ValueError, "invalid-device"): + bridge.validate_device_id("phone; shutdown") + + def test_send_file_requires_a_regular_file(self): + with tempfile.TemporaryDirectory() as directory: + with self.assertRaisesRegex(ValueError, "invalid-file"): + bridge.validate_file(pathlib.Path(directory)) + + def test_action_command_uses_separate_arguments(self): + command = bridge.action_command("ring", "90B54E84548E4F109268EC2D0E7D6930") + self.assertEqual(command, ["kdeconnect-cli", "-d", "90B54E84548E4F109268EC2D0E7D6930", "--ring"]) +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run `python3 tests/quickshell/kdeconnect_bridge_test.py`. + +Expected: FAIL because `panama-kdeconnect` does not exist. + +- [ ] **Step 3: Implement the minimal helper** + +Implement public functions named `validate_device_id`, `validate_file`, +`normalize_device_line`, `action_command`, `collect_status`, `invoke_action`, +and `main`. Use these exact validation and command-building implementations: + +```python +DEVICE_ID = re.compile(r"^[A-Fa-f0-9]{32,64}$") +PLUGIN_ACTIONS = { + "kdeconnect_clipboard": "clipboard", + "kdeconnect_findmyphone": "ring", + "kdeconnect_ping": "ping", + "kdeconnect_share": "share", +} + +def validate_device_id(value: str) -> str: + if not DEVICE_ID.fullmatch(value): + raise ValueError("invalid-device") + return value + +def validate_file(value: pathlib.Path) -> pathlib.Path: + resolved = value.expanduser().resolve(strict=True) + if not resolved.is_file(): + raise ValueError("invalid-file") + return resolved + +def action_command(action: str, device_id: str, file_path: pathlib.Path | None = None) -> list[str]: + device_id = validate_device_id(device_id) + options = { + "ring": ["--ring"], + "clipboard": ["--send-clipboard"], + } + if action == "share" and file_path is not None: + return ["kdeconnect-cli", "-d", device_id, "--share", str(validate_file(file_path))] + if action not in options: + raise ValueError("unsupported-action") + return ["kdeconnect-cli", "-d", device_id, *options[action]] +``` + +`normalize_device_line(line: str, plugins: list[str]) -> dict[str, object]` +must parse one detailed CLI line and omit transport/address text. +`collect_status(runner=subprocess.run) -> dict[str, object]` must return +`available` plus normalized devices. `invoke_action -> dict[str, object]` +must run only `action_command` output. `main(argv: list[str]) -> int` must emit +one compact JSON object and map validation failures to the public error code. + +Call `kdeconnect-cli --list-devices` without a shell. For each parsed device, +call `busctl --user call org.kde.kdeconnect /modules/kdeconnect/devices/DEVICE +org.kde.kdeconnect.device loadedPlugins`; parse only plugin names. Return generic +error codes `unavailable`, `device-offline`, `unsupported-action`, +`invalid-device`, `invalid-file`, or `action-failed`, never raw stderr. + +For actions, construct exactly one of: + +```python +["kdeconnect-cli", "-d", device_id, "--ring"] +["kdeconnect-cli", "-d", device_id, "--send-clipboard"] +["kdeconnect-cli", "-d", device_id, "--share", str(file_path)] +``` + +Make the helper executable. + +- [ ] **Step 4: Run unit tests and verify GREEN** + +Run `python3 tests/quickshell/kdeconnect_bridge_test.py`. + +Expected: all cases PASS with no device name, address, or file path printed by the test runner. + +- [ ] **Step 5: Add and run the redacted live contract** + +Create `tests/quickshell/kdeconnect-helper-contract.sh` that runs `status` and asserts: + +```bash +jq -e ' + .available == true and + (.devices | type == "array" and length >= 1) and + ([.devices[] | has("id") and has("name") and has("type") and has("paired") and has("reachable") and has("actions")] | all) and + ([.devices[].actions[] | IN("clipboard", "ping", "ring", "share")] | all) +' <<<"$status" >/dev/null +``` + +Print only `PASS (N paired, N reachable; identities redacted)`. + +- [ ] **Step 6: Commit Task 1** + +```bash +git add config/dot/quickshell/scripts/panama-kdeconnect \ + tests/quickshell/kdeconnect_bridge_test.py \ + tests/quickshell/kdeconnect-helper-contract.sh +git commit -m "Add the Panama KDE Connect bridge" +``` + +--- + +### Task 2: Home Assistant helper contract + +**Files:** +- Create: `config/dot/quickshell/scripts/panama-home-assistant` +- Create: `tests/quickshell/home_assistant_bridge_test.py` +- Create: `tests/quickshell/home-assistant-helper-contract.sh` + +**Interfaces:** +- Produces: `probe`, `snapshot`, `toggle ENTITY_ID`, and `open`. +- Produces snapshot fields: `ok`, `configured`, `generatedAt`, `entities`, and `error`. +- Produces entity fields: `id`, `name`, `domain`, `state`, `available`, and `active`. +- Configuration precedence: `PANAMA_HOME_ASSISTANT_*`, GNOME extension dconf, then Secret Service for the token. + +- [ ] **Step 1: Write failing configuration, normalization, and fake-API tests** + +Create `tests/quickshell/home_assistant_bridge_test.py` with a local +`ThreadingHTTPServer`. Cover: + +```python +def test_environment_configuration_wins_over_legacy_sources(self): + env = { + "PANAMA_HOME_ASSISTANT_URL": "https://home.example", + "PANAMA_HOME_ASSISTANT_TOKEN": "fixture-token", + "PANAMA_HOME_ASSISTANT_ENTITIES": "light.kitchen,light.hall", + } + config = bridge.resolve_config(env, legacy=lambda: (_ for _ in ()).throw(AssertionError())) + self.assertEqual(config.entity_ids, ("light.kitchen", "light.hall")) + +def test_snapshot_filters_and_preserves_configured_order(self): + raw = [ + {"entity_id": "light.hall", "state": "off", "attributes": {"friendly_name": "Hall"}}, + {"entity_id": "sensor.private", "state": "1", "attributes": {}}, + {"entity_id": "light.kitchen", "state": "on", "attributes": {"friendly_name": "Kitchen"}}, + ] + result = bridge.normalize_entities(raw, ("light.kitchen", "light.hall")) + self.assertEqual([item["name"] for item in result], ["Kitchen", "Hall"]) + self.assertNotIn("sensor.private", json.dumps(result)) + +def test_toggle_rejects_unconfigured_entity(self): + with self.assertRaisesRegex(ValueError, "entity-not-configured"): + bridge.ensure_configured("light.office", ("light.kitchen",)) + +def test_authentication_error_is_redacted(self): + self.assertEqual(bridge.public_http_error(401, "sensitive response"), "authentication-required") +``` + +The fake server must also assert the request uses +`Authorization: Bearer fixture-token`, that snapshot calls `GET /api/states`, +and that toggle calls `POST /api/services/homeassistant/toggle` with only +`{"entity_id":"light.kitchen"}`. Do not print the header. + +- [ ] **Step 2: Run the tests and verify RED** + +Run `python3 tests/quickshell/home_assistant_bridge_test.py`. + +Expected: FAIL because `panama-home-assistant` does not exist. + +- [ ] **Step 3: Implement configuration and REST boundaries** + +Implement the exact `Config` type and these public functions: + +```python +@dataclass(frozen=True) +class Config: + base_url: str + token: str + entity_ids: Sequence[str] + +def ensure_configured(entity_id: str, configured: Sequence[str]) -> str: + if entity_id not in configured: + raise ValueError("entity-not-configured") + return entity_id + +def public_http_error(status: int, body: str) -> str: + del body + return "authentication-required" if status in {401, 403} else "request-failed" + +def normalize_entities(raw: list[dict[str, object]], configured: Sequence[str]) -> list[dict[str, object]]: + by_id = {str(item.get("entity_id", "")): item for item in raw} + result = [] + for entity_id in configured: + item = by_id.get(entity_id) + if not item or not isinstance(item.get("attributes"), dict): + continue + state = str(item.get("state", "unavailable")) + available = state not in {"unknown", "unavailable"} + result.append({ + "id": entity_id, + "name": str(item["attributes"].get("friendly_name") or entity_id.split(".", 1)[1].replace("_", " ").title()), + "domain": entity_id.split(".", 1)[0], + "state": state, + "available": available, + "active": available and state not in {"off", "closed", "idle", "standby"}, + }) + return result +``` + +`resolve_config(env, legacy=load_legacy_config) -> Config` implements the +documented precedence. `load_legacy_config(runner=subprocess.run) -> Config` +reads only the named dconf and Secret Service values. `request_json` +performs one authorized request. `collect_snapshot(config) -> dict` calls +`GET /api/states`; `toggle(config, entity_id) -> dict` validates then calls the +Home Assistant toggle service; `main(argv: list[str]) -> int` emits compact JSON. + +Read `config/bash/env` only through a non-interactive Bash subprocess that emits +the three named Panama values and nothing else. The legacy path reads only +`hass-url`, `hass-enabled-entities`, and `secret-tool lookup token_string +user_token`. Validate the base URL as HTTP(S), strip a trailing slash, reject +entity IDs outside `^[a-z_]+\.[a-z0-9_]+$`, deduplicate while preserving order, +and use `urllib.request` with a 5-second timeout. + +Map state as follows: + +```python +available = state not in {"unknown", "unavailable"} +active = available and state not in {"off", "closed", "idle", "standby"} +``` + +Return only normalized entities. Map 401/403 to `authentication-required`, +timeouts and transport errors to `unreachable`, and malformed JSON to +`invalid-response`. + +- [ ] **Step 4: Run unit tests and verify GREEN** + +Run `python3 tests/quickshell/home_assistant_bridge_test.py`. + +Expected: all tests PASS and the fake server records no unexpected route. + +- [ ] **Step 5: Add and run the redacted live contract** + +The contract runs `probe` and `snapshot`, validates the schema, and prints only +`PASS (configured=true, N favourites; contents redacted)`. It must pipe JSON to +`jq` without printing friendly names, entity IDs, URL, or raw errors. + +- [ ] **Step 6: Commit Task 2** + +```bash +git add config/dot/quickshell/scripts/panama-home-assistant \ + tests/quickshell/home_assistant_bridge_test.py \ + tests/quickshell/home-assistant-helper-contract.sh +git commit -m "Add the Panama Home Assistant bridge" +``` + +--- + +### Task 3: Quickshell service state and Ongoing integration + +**Files:** +- Create: `config/dot/quickshell/services/KdeConnect.qml` +- Create: `config/dot/quickshell/services/HomeAssistant.qml` +- Create: `tests/quickshell/control-center-services-contract.sh` +- Modify: `config/dot/quickshell/services/DeviceEvents.qml` +- Modify: `config/dot/quickshell/services/Ongoing.qml` +- Modify: `config/dot/quickshell/shell.qml` + +**Interfaces:** +- `KdeConnect`: `available`, `devices`, `preferredPhone`, `phoneReachable`, `transferActive`, `transferFileName`, `recentExchange`, `refresh()`, `sendFile(path)`, `sendClipboard()`, `ring()`, `cancelTransfer()`, `applyFixture(name)`, `clearFixture()`. +- `HomeAssistant`: `phase`, `entities`, `visibleEntities`, `configuredCount`, `stale`, `busyEntityId`, `lastError`, `refresh()`, `toggleEntity(id)`, `open()`, `applyFixture(name)`, `clearFixture()`. +- IPC targets: `kdeconnect` and `home-assistant` with fixture, reset, refresh, action-safe diagnostics, and status functions. + +- [ ] **Step 1: Write the failing live fixture contract** + +Create a shell test that asserts both IPC targets, then exercises synthetic +fixtures only: + +```bash +qs ipc call kdeconnect fixture reachable >/dev/null +jq -e '.fixture and .reachable and .actionCount == 4 and .transferActive == false' \ + <<<"$(qs ipc call kdeconnect status)" >/dev/null + +qs ipc call kdeconnect fixture transfer >/dev/null +jq -e '.transferActive and .ongoingCount >= 1' \ + <<<"$(qs ipc call kdeconnect status)" >/dev/null + +qs ipc call home-assistant fixture ready >/dev/null +jq -e '.fixture and .phase == "ready" and .configuredCount == 7 and .visibleCount == 4' \ + <<<"$(qs ipc call home-assistant status)" >/dev/null + +qs ipc call home-assistant fixture stale >/dev/null +jq -e '.phase == "degraded" and .stale and .configuredCount == 7' \ + <<<"$(qs ipc call home-assistant status)" >/dev/null +``` + +Cleanup resets both fixture modes and closes Quick Settings. + +- [ ] **Step 2: Run the contract and verify RED** + +Run `tests/quickshell/control-center-services-contract.sh`. + +Expected: FAIL because the IPC targets do not exist. + +- [ ] **Step 3: Implement `KdeConnect.qml`** + +Use one status `Process`, one action `Process`, and one low-frequency `Timer`. +Parse one JSON object per completion. Fixture data contains only synthetic names. +Expose `preferredPhone` as the first reachable `type === "phone"`, then the +first paired phone. Keep action commands as arrays: + +```qml +actionProcess.command = [helperPath, "send-file", phone.id, path] +actionProcess.running = true +``` + +While `send-file` runs, set `transferActive`, basename-only +`transferFileName`, and the device name. On success, set `recentExchange` and +publish one important `StatusEvents` completion with an `open-path` action only +when a local path is safe to reopen. Ring and clipboard completion publish no +event. `cancelTransfer()` terminates only the owned action process. + +- [ ] **Step 4: Implement `HomeAssistant.qml`** + +Use separate refresh and action `Process` objects. Preserve `entities` when a +refresh fails after a successful snapshot and set `stale=true`. Use one +`busyEntityId` because the UI allows one Home request at a time. After a +successful toggle, immediately run `refresh()`. Expose the first four entities +as `visibleEntities` and retain all configured entities for expansion. + +- [ ] **Step 5: Wire shared activity and transitions** + +Replace `DeviceEvents.qml`'s private KDE polling process with properties and +change handlers bound to `KdeConnect.phoneReachable` and the preferred phone's +name. Add this activity to `Ongoing.activities`: + +```qml +if (KdeConnect.transferActive) { + result.push({ + kind: "phone-transfer", + glyph: "\u{F03F2}", + label: "Sending to " + KdeConnect.transferDeviceName, + detail: KdeConnect.transferFileName, + state: "Sending", + tone: "accent", + action: "Cancel" + }); +} +``` + +Route `Ongoing.invoke("phone-transfer")` to `KdeConnect.cancelTransfer()`. +Add typed diagnostic IPC methods without any command that performs a live Home +toggle, Ring, clipboard send, or file send. + +- [ ] **Step 6: Restart Quickshell and run the service contract GREEN** + +Run the repository's established shell reload command, wait for the current +instance to report IPC, then run: + +```bash +tests/quickshell/control-center-services-contract.sh +``` + +Expected: PASS with fixture state only and no QML warnings in the fresh journal. + +- [ ] **Step 7: Commit Task 3** + +```bash +git add config/dot/quickshell/services/KdeConnect.qml \ + config/dot/quickshell/services/HomeAssistant.qml \ + config/dot/quickshell/services/DeviceEvents.qml \ + config/dot/quickshell/services/Ongoing.qml \ + config/dot/quickshell/shell.qml \ + tests/quickshell/control-center-services-contract.sh +git commit -m "Add Control Center integration services" +``` + +--- + +### Task 4: Approved Control Center interface + +**Files:** +- Create: `config/dot/quickshell/modules/quicksettings/ControlSectionHeader.qml` +- Create: `config/dot/quickshell/modules/quicksettings/HomeControls.qml` +- Create: `config/dot/quickshell/modules/quicksettings/HomeTile.qml` +- Create: `config/dot/quickshell/modules/quicksettings/PhoneControls.qml` +- Create: `config/dot/quickshell/modules/quicksettings/RecentExchange.qml` +- Create: `tests/quickshell/control-center-contract.sh` +- Modify: `config/dot/quickshell/config/Theme.qml` +- Modify: `config/dot/quickshell/modules/quicksettings/QuickSettings.qml` +- Modify: `config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml` +- Modify: `config/dot/quickshell/modules/bar/StatusCluster.qml` + +**Interfaces:** +- `HomeControls.expanded: bool` and signal `toggleExpanded()`. +- `PhoneControls.expanded: bool` and signal `toggleExpanded()`. +- Existing `expandedSection` remains the only expansion owner. +- `StatusCluster` remains one click target and reads `KdeConnect.phoneReachable`. + +- [ ] **Step 1: Write the failing panel contract** + +Assert source and live behavior: + +```bash +rg -q 'readonly property int controlCenterWidth: 430' config/dot/quickshell/config/Theme.qml +rg -q 'readonly property int controlCenterTopGap: 2' config/dot/quickshell/config/Theme.qml +rg -q 'margins.top: Theme.barHeight + Theme.controlCenterTopGap' config/dot/quickshell/modules/quicksettings/QuickSettings.qml +rg -q 'HomeControls' config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml +rg -q 'PhoneControls' config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml +rg -q 'visible: KdeConnect.phoneReachable' config/dot/quickshell/modules/bar/StatusCluster.qml +``` + +Load ready fixtures, open Quick Settings, and assert `hyprctl layers` contains +`qs-popover-quicksettings`. Use IPC status to assert opening Home followed by +Phone leaves only Phone expanded. + +- [ ] **Step 2: Run the contract and verify RED** + +Run `tests/quickshell/control-center-contract.sh`. + +Expected: FAIL on the missing geometry tokens and components. + +- [ ] **Step 3: Add geometry and composition** + +Add exact tokens: + +```qml +readonly property int controlCenterWidth: 430 +readonly property int controlCenterTopGap: 2 +``` + +Set both Quick Settings implicit widths to `Theme.controlCenterWidth`, set top +margin to `Theme.barHeight + Theme.controlCenterTopGap`, and retain the current +right margin. On open, call `KdeConnect.refresh()` and +`HomeAssistant.refresh()` before restarting the entrance animation. + +Place Home after audio/brightness and Phone after Home, before the footer. +Reuse `expandedSection` values `home` and `phone`; do not introduce nested +popovers. + +- [ ] **Step 4: Implement the Home components** + +`HomeTile.qml` is a button-like rectangle with `entity`, `busy`, and +`onActivated`. It maps `light` to a lamp glyph, uses `Theme.warn` at low alpha +only when `entity.active`, and shows `Unavailable` for `!entity.available`. + +`HomeControls.qml` uses a four-column resting grid from +`HomeAssistant.visibleEntities`. Expanded mode uses a single-column list of all +`HomeAssistant.entities`. Include deterministic loading, setup, +authentication-required, unreachable/stale, and empty states. Retry calls +`HomeAssistant.refresh()`; Open calls `HomeAssistant.open()`. + +- [ ] **Step 5: Implement the Phone components and chooser** + +`PhoneControls.qml` renders the approved identity-first card. Show only actions +included in `preferredPhone.actions`. Use `QtQuick.Dialogs.FileDialog` with a +single existing-file selection. `onAccepted` passes the selected local file path +as a distinct argument to `KdeConnect.sendFile()`; `onRejected` does nothing. + +Disabled/offline copy is `Not nearby`. Missing KDE Connect copy is +`KDE Connect unavailable`. `RecentExchange.qml` is visible only when the +service has a real current-session exchange. + +- [ ] **Step 6: Add reachable-phone status and visual states** + +Append one phone glyph to the existing `StatusCluster` only when +`KdeConnect.phoneReachable`; it has no MouseArea and inherits the parent Pill's +activation. Use `Theme.cyan` for reachable, no disconnected glyph, and no +animation. + +- [ ] **Step 7: Restart Quickshell and run the panel contract GREEN** + +Run `tests/quickshell/control-center-contract.sh` and inspect the fresh shell +journal for `error`, `warning`, `ReferenceError`, and `TypeError` entries from +the current Quickshell start. + +Capture resting, Home-expanded, Phone-offline, transfer-active, and Home-stale +fixture screenshots to `/tmp`; do not commit screenshots. + +- [ ] **Step 8: Commit Task 4** + +```bash +git add config/dot/quickshell/config/Theme.qml \ + config/dot/quickshell/modules/quicksettings \ + config/dot/quickshell/modules/bar/StatusCluster.qml \ + tests/quickshell/control-center-contract.sh +git commit -m "Build the Panama Control Center" +``` + +--- + +### Task 5: Documentation and full verification + +**Files:** +- Modify: `config/dot/hypr/DESKTOP-PARITY.md` +- Modify: `config/dot/hypr/README.md` + +**Interfaces:** +- Consumes the complete helper, service, UI, and IPC contracts from Tasks 1–4. +- Produces an auditable verification result and an up-to-date remaining roadmap. + +- [ ] **Step 1: Update desktop documentation** + +Mark Home Assistant favourites and KDE Connect phone integration Live. Document +the 430-pixel Control Center, two-pixel bar attachment, environment/keyring +configuration precedence, BlueBubbles messaging boundary, and current iOS +capabilities. Remove those two items from `System UI still worth adding` while +retaining printers and the external RustDesk lock-screen test. + +- [ ] **Step 2: Run every focused test** + +```bash +python3 tests/quickshell/kdeconnect_bridge_test.py +python3 tests/quickshell/home_assistant_bridge_test.py +tests/quickshell/kdeconnect-helper-contract.sh +tests/quickshell/home-assistant-helper-contract.sh +tests/quickshell/control-center-services-contract.sh +tests/quickshell/control-center-contract.sh +``` + +Expected: every command exits 0; live contracts print counts only. + +- [ ] **Step 3: Run full Panama verification** + +Run `hyprctl configerrors` and require empty output. Run every executable +`tests/quickshell/*contract.sh` in sorted order and require zero failures. +Restart Quickshell from a clean process, verify all IPC targets return, and +inspect only the new process's journal for QML errors or warnings. + +- [ ] **Step 4: Perform read-only live checks** + +Verify the Control Center reports one reachable phone with the confirmed action +names and seven configured Home favourites without printing identities. Open +and close the panel, expand Home and Phone, and confirm Wi-Fi, Bluetooth, audio, +Focus, Caffeine, Night Light, settings, power, Escape, and outside-click remain +usable. Do not trigger an external-device action. + +- [ ] **Step 5: Review the final diff against the spec** + +Check every Goal, Non-goal, Interaction, Security, Failure, Testing, and +Documentation requirement in +`docs/superpowers/specs/2026-08-17-panama-control-center-design.md`. Run +`git diff --check` and confirm no secret-shaped value, live entity response, +device ID, IP address, or absolute personal file path entered tracked content. + +- [ ] **Step 6: Commit documentation and push the feature** + +```bash +git add config/dot/hypr/DESKTOP-PARITY.md config/dot/hypr/README.md +git commit -m "Document the Panama Control Center" +git push origin main +``` + +- [ ] **Step 7: Offer the three explicit live action checks** + +Ask the user to choose actions from the finished Control Center: toggle one Home +light, Ring the iPhone, send clipboard text, or send a harmless chosen file. +Treat the visual state and resulting external device behavior as the acceptance +signal; do not execute an external action from diagnostics or automation.