# Panama Home Accessories Customization 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:** Build the approved Accessory Shelf, a durable Home & Phone settings page for selecting, ordering, and naming Home Assistant lights, brightness controls that commit on release, and an independent BlueBubbles Messages action.

**Architecture:** The Python helper remains the credential-bearing Home Assistant boundary and exposes a complete normalized light catalog plus allow-checked actions. A new `HomePreferences` singleton atomically persists only selected IDs, order, aliases, and initialization state; `HomeAssistant.qml` combines that state with live catalog data and owns per-entity action queues/errors. Settings and Control Center render the same service model, while `SystemSettings` owns BlueBubbles detection and its fixed argument-vector launch.

**Tech Stack:** Quickshell 0.3 QML, QtQuick, Quickshell.Io `FileView`/`JsonAdapter`, Python 3 standard library, Home Assistant REST API, Flatpak CLI, Bash contract tests, `unittest`, `jq`, Hyprland IPC

**Spec:** `docs/superpowers/specs/2026-08-17-home-accessories-customization-design.md`

## Global Constraints

- Preserve the approved A · Accessory Shelf visual direction: a quiet two-column grid, two rows at rest, Prism/Tokyo Night Moon styling, and always-visible amber dimmers.
- The resting shelf contains the first four selected lights; the expanded shelf contains every selected light in preference order.
- Slider movement is preview-only; release sends exactly one request. Percent 1–100 calls `light.turn_on` with `brightness_pct`, while zero calls `light.turn_off`.
- Power actions and successful brightness actions remain globally quiet. A failure is shown only on the affected light and restores its confirmed value.
- Aliases are Panama-only. Do not rename Home Assistant entities or friendly names.
- Persist only `{ initialized, favorites: [{ id, alias }] }` in Quickshell state. Never persist or log the Home Assistant URL, token, catalog response, or response bodies.
- Preserve private Panama environment values as the first credential source, followed by the GNOME extension URL and Secret Service token.
- Use the legacy selected-light list only for the first successful initialization. An intentionally empty initialized selection must remain empty.
- Keep missing selected entities in preference order and present them as unavailable until the user removes them.
- Messages launches `flatpak run app.bluebubbles.BlueBubbles` as separate arguments and is never gated by KDE Connect reachability.
- Automated verification must not toggle or dim a real light and must not launch BlueBubbles.
- Keep existing Control Center, Settings, notifications, Ongoing, calendar, KDE Connect, and shell contracts compatible.

---

## File Structure

- `config/dot/quickshell/scripts/panama-home-assistant` — credential-safe catalog, action authorization, brightness parsing, and Home Assistant REST calls.
- `tests/quickshell/home_assistant_bridge_test.py` — fake-server unit tests for filtering, normalization, authorization, payloads, and redaction.
- `tests/quickshell/home-assistant-helper-contract.sh` — live read-only probe/catalog shape check; it never invokes an action command.
- `config/dot/quickshell/config/HomePreferences.qml` — sole durable owner of initialization state and ordered `{id, alias}` favorites.
- `config/dot/quickshell/config/qmldir` — registers `HomePreferences` as a singleton.
- `config/dot/quickshell/home-preferences-harness.qml` — isolated IPC surface for preference mutation and restart tests.
- `tests/quickshell/home-preferences-contract.sh` — first-run, empty-state, alias, reorder, remove, and restart-persistence contract.
- `config/dot/quickshell/services/HomeAssistant.qml` — complete catalog, preference resolution, four-item shelf, fixtures, sequential action queue, and per-entity pending/error maps.
- `config/dot/quickshell/shell.qml` — typed Home Assistant diagnostics/actions for fixture-only testing and Home & Phone routing.
- `tests/quickshell/control-center-services-contract.sh` — service-model and per-light action-state contract using fixtures only.
- `config/dot/quickshell/services/SystemSettings.qml` — BlueBubbles installed-state query and fixed allow-listed launch vector.
- `config/dot/quickshell/modules/settings/HomePhonePage.qml` — page composition, connection health, search, selected/available sections, save error, and phone continuity.
- `config/dot/quickshell/modules/settings/HomeFavoriteCard.qml` — editable alias, source name, first-four badge, remove action, and drag handle.
- `config/dot/quickshell/modules/settings/AvailableLightRow.qml` — searchable unselected-light row with Add action.
- `config/dot/quickshell/modules/settings/SettingsSidebar.qml` — Home & Phone destination between Network & Devices and Desktop & Dock.
- `config/dot/quickshell/modules/settings/SettingsShell.qml` — page loader registration.
- `config/dot/quickshell/modules/settings/qmldir` — component registrations.
- `config/dot/quickshell/services/ShellState.qml` — `home-phone` settings-page allow-list entry.
- `tests/quickshell/settings-pages-contract.sh` — route and single-window behavior.
- `tests/quickshell/home-phone-settings-contract.sh` — static and fixture-driven settings-page behavior without writing the user's real preferences.
- `config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml` — local preview and one-shot commit interaction.
- `config/dot/quickshell/modules/quicksettings/HomeTile.qml` — polished power, state, percent, dimmer, busy, and inline-error presentation.
- `config/dot/quickshell/modules/quicksettings/HomeControls.qml` — resting/expanded shelf, setup state, stale state, and Manage in Settings row.
- `config/dot/quickshell/modules/quicksettings/PhoneControls.qml` — four equal actions and independent Messages enablement.
- `tests/quickshell/control-center-contract.sh` — mapped panel, exclusive expansion, component presence, and approved shelf structure.
- `tests/quickshell/phone-messages-contract.sh` — BlueBubbles detection/allow-list/independent-enable contract; it never invokes the action.

---

### Task 1: Complete Home Assistant Catalog and Brightness Boundary

**Files:**
- Modify: `config/dot/quickshell/scripts/panama-home-assistant:44-434`
- Modify: `tests/quickshell/home_assistant_bridge_test.py:25-182`
- Modify: `tests/quickshell/home-assistant-helper-contract.sh:1-29`

**Interfaces:**
- Consumes: `Config(base_url: str, token: str, entity_ids: tuple[str, ...])`, `request_json(config, method, path, payload)` and current URL/token/legacy resolution.
- Produces: `normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]`, `collect_catalog(config: Config) -> dict[str, object]`, `discovered_light_ids(config: Config) -> set[str]`, `toggle(config: Config, entity_id: str) -> dict[str, object]`, `set_brightness(config: Config, entity_id: str, percent: int) -> dict[str, object]`, and CLI commands `catalog`, `toggle ENTITY_ID`, `brightness ENTITY_ID PERCENT`.
- Produces catalog entities with exactly `id`, `sourceName`, `state`, `available`, `active`, `dimmable`, and `brightnessPct`; the top level also contains `legacyEntityIds` for one-time migration.

- [x] **Step 1: Expand the fake Home Assistant and write failing catalog tests**

Change the fake `/api/states` response to include an on dimmable light, an off dimmable light, a sensor, a malformed light, and an unavailable light:

```python
[
    {
        "entity_id": "light.kitchen",
        "state": "on",
        "attributes": {
            "friendly_name": "Kitchen",
            "brightness": 128,
            "supported_color_modes": ["brightness"],
        },
    },
    {
        "entity_id": "light.hall",
        "state": "off",
        "attributes": {
            "friendly_name": "Hall",
            "supported_color_modes": ["color_temp"],
        },
    },
    {"entity_id": "sensor.private", "state": "1", "attributes": {"token": "never-return"}},
    {"entity_id": "light.malformed", "state": "on", "attributes": "invalid"},
    {
        "entity_id": "light.corner",
        "state": "unavailable",
        "attributes": {"friendly_name": "Corner", "supported_color_modes": ["brightness"]},
    },
]
```

Add assertions that `collect_catalog(self.config())` returns Kitchen, Hall, and Corner in source order; returns no sensor, malformed entity, or raw attribute key; rounds Kitchen brightness to 50; sets Hall brightness to 0; and reports all three as dimmable.

- [x] **Step 2: Write failing action authorization and payload tests**

Teach the fake POST handler to accept all three exact service routes and record bodies. Add these tests:

```python
def test_toggle_authorizes_against_discovered_catalog(self) -> None:
    result = bridge.toggle(self.config(), "light.corner")
    self.assertTrue(result["ok"])
    self.assertEqual(FakeHomeAssistant.requests[-1]["path"], "/api/services/homeassistant/toggle")

def test_action_rejects_an_entity_not_in_the_live_catalog(self) -> None:
    with self.assertRaisesRegex(ValueError, "entity-not-discovered"):
        bridge.toggle(self.config(), "light.office")

def test_brightness_uses_turn_on_for_positive_percent(self) -> None:
    bridge.set_brightness(self.config(), "light.kitchen", 62)
    request = FakeHomeAssistant.requests[-1]
    self.assertEqual(request["path"], "/api/services/light/turn_on")
    self.assertEqual(json.loads(request["body"]), {"entity_id": "light.kitchen", "brightness_pct": 62})

def test_brightness_zero_uses_turn_off(self) -> None:
    bridge.set_brightness(self.config(), "light.hall", 0)
    request = FakeHomeAssistant.requests[-1]
    self.assertEqual(request["path"], "/api/services/light/turn_off")
    self.assertEqual(json.loads(request["body"]), {"entity_id": "light.hall"})
```

Add table-driven validation for `-1`, `101`, `1.5`, and `bright`, expecting `invalid-brightness` before any POST. Keep the existing authentication-redaction and configuration-precedence tests.

- [x] **Step 3: Run the focused unit suite and verify RED**

Run:

```bash
python3 tests/quickshell/home_assistant_bridge_test.py -v
```

Expected: failures identify missing `collect_catalog`, live-catalog authorization, and `set_brightness`; the existing tests remain green.

- [x] **Step 4: Implement normalized catalog output**

Change `Config.configured` to require only `base_url` and `token`; `entity_ids` becomes migration metadata, not an operational requirement. Replace configured-only normalization with:

```python
def normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]:
    result: list[dict[str, object]] = []
    for item in raw:
        if not isinstance(item, dict):
            continue
        entity_id = item.get("entity_id")
        attributes = item.get("attributes")
        if not isinstance(entity_id, str) or not entity_id.startswith("light."):
            continue
        if not ENTITY_ID.fullmatch(entity_id) or not isinstance(attributes, dict):
            continue
        state = str(item.get("state", "unavailable"))
        available = state not in {"unknown", "unavailable"}
        active = available and state == "on"
        raw_brightness = attributes.get("brightness")
        brightness_pct = (
            round(max(0, min(255, raw_brightness)) * 100 / 255)
            if active and isinstance(raw_brightness, (int, float)) and not isinstance(raw_brightness, bool)
            else 0
        )
        modes = attributes.get("supported_color_modes", [])
        dimmable = (
            isinstance(modes, list) and any(mode != "onoff" for mode in modes)
        ) or isinstance(raw_brightness, (int, float))
        source_name = attributes.get("friendly_name")
        result.append({
            "id": entity_id,
            "sourceName": source_name.strip() if isinstance(source_name, str) and source_name.strip() else fallback_name(entity_id),
            "state": state,
            "available": available,
            "active": active,
            "dimmable": dimmable,
            "brightnessPct": brightness_pct,
        })
    return result
```

`collect_catalog()` must return `legacyEntityIds: list(config.entity_ids)` on both success and safe failures, while retaining the current `ok`, `configured`, `generatedAt`, `entities`, and redacted `error` envelope. Keep `snapshot` as a compatibility alias for this release, but make QML and live shape tests call `catalog`.

- [x] **Step 5: Implement discovered-light authorization and brightness commands**

Fetch `/api/states` immediately before every action, derive a set only from `normalize_catalog()`, and reject anything absent with `ValueError("entity-not-discovered")`. Validate the percentage as an ASCII integer string at the CLI boundary and as an integer in `set_brightness()`; reject booleans and values outside 0–100 with `ValueError("invalid-brightness")`.

Change `request_json()`'s payload annotation from `dict[str, str] | None` to `Mapping[str, object] | None` so the integer `brightness_pct` is represented honestly. Parse the CLI value with:

```python
def parse_brightness(value: str) -> int:
    if not re.fullmatch(r"(?:0|[1-9][0-9]{0,2})", value):
        raise ValueError("invalid-brightness")
    percent = int(value)
    if percent > 100:
        raise ValueError("invalid-brightness")
    return percent
```

Use exact payloads:

```python
def set_brightness(config: Config, entity_id: str, percent: int) -> dict[str, object]:
    if isinstance(percent, bool) or not isinstance(percent, int) or not 0 <= percent <= 100:
        raise ValueError("invalid-brightness")
    ensure_discovered(config, entity_id)
    if percent == 0:
        path = "/api/services/light/turn_off"
        payload = {"entity_id": entity_id}
    else:
        path = "/api/services/light/turn_on"
        payload = {"entity_id": entity_id, "brightness_pct": percent}
    request_json(config, "POST", path, payload)
    return {"ok": True, "entityId": entity_id, "brightnessPct": percent, "error": ""}
```

Do not print the discovery response, request body, URL, or token on action failure.

- [x] **Step 6: Make unit and live read-only contracts GREEN**

Update `home-assistant-helper-contract.sh` to call `catalog`, assert the exact seven entity keys, assert `legacyEntityIds` is an array, and report only the count:

```bash
catalog="$($helper catalog)"
jq -e '
  .ok == true and .configured == true and .error == "" and
  (.entities | type == "array" and length > 0) and
  ([.entities[] | (keys | sort) == (["active", "available", "brightnessPct", "dimmable", "id", "sourceName", "state"] | sort)] | all) and
  ([.entities[] | (.id | startswith("light.")) and (.brightnessPct >= 0 and .brightnessPct <= 100)] | all) and
  (.legacyEntityIds | type == "array")
' <<<"$catalog" >/dev/null
```

Run:

```bash
python3 tests/quickshell/home_assistant_bridge_test.py -v
tests/quickshell/home-assistant-helper-contract.sh
```

Expected: all unit tests PASS; the live contract prints a redacted light count and performs GET requests only.

- [x] **Step 7: Commit the helper boundary**

```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 Home Assistant light catalog and dimming"
```

---

### Task 2: Persist Home Favorites, Aliases, and Order

**Files:**
- Create: `config/dot/quickshell/config/HomePreferences.qml`
- Modify: `config/dot/quickshell/config/qmldir:1-4`
- Create: `config/dot/quickshell/home-preferences-harness.qml`
- Create: `tests/quickshell/home-preferences-contract.sh`

**Interfaces:**
- Consumes: top-level helper field `legacyEntityIds: string[]` after the first successful catalog.
- Produces: `initialized: bool`, `favorites: var`, `saveError: string`, `initialize(legacyIds)`, `isSelected(entityId)`, `aliasFor(entityId, sourceName)`, `add(entityId)`, `remove(entityId)`, `setAlias(entityId, alias)`, `move(entityId, targetIndex)`, `retrySave()`, and state file `Quickshell.stateDir + "/panama-home.json"`.
- Produces favorite records with exactly `{ id: string, alias: string }`; every mutator assigns a cloned array so QML change notification and persistence are deterministic.

- [x] **Step 1: Write the isolated persistence harness and failing restart contract**

Create an IPC harness with these methods:

```qml
IpcHandler {
    target: "home-pref-test"
    function initialize(idsJson: string): void { HomePreferences.initialize(JSON.parse(idsJson)); }
    function add(id: string): void { HomePreferences.add(id); }
    function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
    function move(id: string, index: int): void { HomePreferences.move(id, index); }
    function remove(id: string): void { HomePreferences.remove(id); }
    function status(): string {
        return JSON.stringify({
            initialized: HomePreferences.initialized,
            favorites: HomePreferences.favorites,
            saveError: HomePreferences.saveError,
            stateDir: Quickshell.stateDir
        });
    }
}
```

The Bash contract must use a temporary `XDG_STATE_HOME`, initialize `light.kitchen`, `light.hall`, `light.desk`, then alias Kitchen to `  Island  `, move Desk to index 0, remove Hall, restart the harness, and assert:

```json
{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}],"saveError":""}
```

Then remove both records, restart again, call `initialize` with a different legacy list, and assert the selection stays empty. Assert the JSON file contains only `initialized` and `favorites` keys and no strings matching `token`, `url`, or `api` case-insensitively.

- [x] **Step 2: Run the contract and verify RED**

Run:

```bash
tests/quickshell/home-preferences-contract.sh
```

Expected: FAIL because the singleton and harness do not exist.

- [x] **Step 3: Implement the atomic preference singleton**

Register `singleton HomePreferences 1.0 HomePreferences.qml`. Build `HomePreferences.qml` around this adapter:

```qml
property alias initialized: values.initialized
property alias favorites: values.favorites
property string saveError: ""

FileView {
    id: preferencesFile
    path: Quickshell.stateDir + "/panama-home.json"
    blockLoading: true
    printErrors: false
    atomicWrites: true
    onSaved: root.saveError = ""
    onSaveFailed: error => root.saveError = "Could not save Home favourites."
    JsonAdapter {
        id: values
        property bool initialized: false
        property var favorites: []
    }
}
```

Use a 180 ms single-shot persistence timer. `initialize()` filters IDs through `^light\.[a-z0-9_]+$`, removes duplicates while preserving order, seeds only when `initialized === false`, then sets `initialized = true`. `setAlias()` trims with `String(value).trim()`. `move()` clamps the target index to `0..length - 1`. `retrySave()` directly invokes `preferencesFile.writeAdapter()`. On save failure, leave the mutated `favorites` array untouched so the user can retry without re-entering edits.

- [x] **Step 4: Run the restart contract and inspect the private file shape**

Run:

```bash
tests/quickshell/home-preferences-contract.sh
```

Expected: PASS for initial migration, trim, reorder, remove, restart persistence, and initialized-empty behavior. The temporary file is valid JSON and contains no credential-like fields.

- [x] **Step 5: Commit preference ownership**

```bash
git add config/dot/quickshell/config/HomePreferences.qml \
  config/dot/quickshell/config/qmldir \
  config/dot/quickshell/home-preferences-harness.qml \
  tests/quickshell/home-preferences-contract.sh
git commit -m "Persist Home accessory preferences"
```

---

### Task 3: Compose Catalog and Preferences in the QML Service

**Files:**
- Modify: `config/dot/quickshell/services/HomeAssistant.qml:1-185`
- Modify: `config/dot/quickshell/shell.qml:252-272`
- Modify: `tests/quickshell/control-center-services-contract.sh:10-90`

**Interfaces:**
- Consumes: helper `catalog`, `toggle ENTITY_ID`, and `brightness ENTITY_ID PERCENT`; all `HomePreferences` interfaces from Task 2.
- Produces: `catalog: var`, `selectedEntities: var`, `visibleEntities: var`, `discoveredCount: int`, `configuredCount: int`, `busyEntityIds: var`, `pendingBrightness: var`, `entityErrors: var`, `refresh()`, `toggleEntity(id)`, `setBrightness(id, percent)`, `isBusy(id)`, `pendingFor(id)`, `errorFor(id)`, and current phase/stale/open behavior.
- Produces selected entity objects with `id`, `sourceName`, `name`, `state`, `available`, `active`, `dimmable`, and `brightnessPct`; `name` is the trimmed Panama alias or `sourceName` fallback.

- [x] **Step 1: Extend the fixture contract for ordering, missing entities, and local action state**

Change the `ready` fixture assertion to require `discoveredCount == 7`, `configuredCount == 7`, `visibleCount == 4`, and ordered aliases. Add fixture-only IPC methods `brightness(id, percent)` and `toggle(id)`, then assert:

```bash
qs ipc call home-assistant fixture ready >/dev/null
before="$(qs ipc call home-assistant status)"
jq -e '.selectedIds[0:4] == ["light.fixture_all", "light.fixture_kitchen", "light.fixture_living", "light.fixture_bedroom"]' <<<"$before"

qs ipc call home-assistant brightness light.fixture_living 64 >/dev/null
jq -e '.entities[] | select(.id == "light.fixture_living") | .active == true and .brightnessPct == 64' \
  <<<"$(qs ipc call home-assistant status)"
```

Add `missing-selected` and `action-error` fixtures. `missing-selected` retains one preferred ID absent from catalog as unavailable. `action-error` returns `entityErrors["light.fixture_kitchen"] == "request-failed"` while Hall has no error and the global phase is still ready.

- [x] **Step 2: Run the service contract and verify RED**

Run:

```bash
tests/quickshell/control-center-services-contract.sh
```

Expected: the new catalog counts, selected IDs, brightness fixture action, and per-entity errors are absent.

- [x] **Step 3: Replace configured entities with catalog/preference resolution**

Import `qs.config`. Change the refresh command to `[helperPath, "catalog"]`. On a successful live catalog:

```qml
root.catalog = Array.isArray(result.entities) ? result.entities : [];
HomePreferences.initialize(Array.isArray(result.legacyEntityIds) ? result.legacyEntityIds : []);
root.rebuildSelection();
root.phase = "ready";
root.stale = false;
root.lastError = "";
```

`rebuildSelection()` maps the ordered preference records. If an ID exists in catalog, copy normalized fields and add `name`. If it is missing, create:

```qml
{
    id: favorite.id,
    sourceName: favorite.id.split(".")[1].replaceAll("_", " "),
    name: favorite.alias || favorite.id.split(".")[1].replaceAll("_", " "),
    state: "unavailable",
    available: false,
    active: false,
    dimmable: false,
    brightnessPct: 0
}
```

Connect to `HomePreferences.favoritesChanged` and rebuild immediately. In fixture mode, resolve against fixture-local favorite records instead of mutating or reading the user's durable list.

- [x] **Step 4: Implement per-entity sequential actions**

Replace the one global busy ID with a queue and cloned maps:

```qml
property var actionQueue: []
property var busyEntityIds: []
property var pendingBrightness: ({})
property var entityErrors: ({})

function isBusy(entityId: string): bool {
    return root.busyEntityIds.indexOf(entityId) >= 0;
}

function setBrightness(entityId: string, percent: int): void {
    if (!Number.isInteger(percent) || percent < 0 || percent > 100)
        return;
    root.enqueueAction({ kind: "brightness", entityId, percent });
}
```

Each accepted action adds only its entity ID to `busyEntityIds`; brightness also stores the requested percentage in `pendingBrightness`. One `Process` runs queue entries in order, allowing unrelated tiles to enqueue without a global disable. Completion removes only that entity's busy/pending fields. Success clears only that entity's error and starts the existing 350 ms catalog refresh. Failure leaves catalog/phase intact, removes the preview so the UI snaps to confirmed brightness, and writes the safe code only to `entityErrors[entityId]`. A new action on an entity clears its previous inline error.

- [x] **Step 5: Expand fixtures and diagnostics without touching durable preferences**

Fixture entities must include brightness percentages, dimmable flags, aliases, one off light, and one unavailable light. Add status fields:

```qml
return JSON.stringify({
    fixture: HomeAssistant.fixtureMode,
    phase: HomeAssistant.phase,
    discoveredCount: HomeAssistant.discoveredCount,
    configuredCount: HomeAssistant.configuredCount,
    visibleCount: HomeAssistant.visibleEntities.length,
    selectedIds: HomeAssistant.selectedEntities.map(entity => entity.id),
    entities: HomeAssistant.selectedEntities,
    stale: HomeAssistant.stale,
    busyEntityIds: HomeAssistant.busyEntityIds,
    pendingBrightness: HomeAssistant.pendingBrightness,
    entityErrors: HomeAssistant.entityErrors,
    lastError: HomeAssistant.lastError
});
```

Fixture actions update only in-memory fixture catalog. `clearFixture()` clears fixture action state and returns to a live `catalog` refresh; it never changes `HomePreferences`.

- [x] **Step 6: Run service and preference regression contracts**

Run:

```bash
tests/quickshell/home-preferences-contract.sh
tests/quickshell/control-center-services-contract.sh
```

Expected: both PASS; fixture cleanup returns live mode and the durable preference contract remains unchanged.

- [x] **Step 7: Commit service composition**

```bash
git add config/dot/quickshell/services/HomeAssistant.qml \
  config/dot/quickshell/shell.qml \
  tests/quickshell/control-center-services-contract.sh
git commit -m "Compose Home catalog with accessory preferences"
```

---

### Task 4: Add BlueBubbles and the Home & Phone Settings Page

**Files:**
- Modify: `config/dot/quickshell/services/SystemSettings.qml:14-237`
- Create: `config/dot/quickshell/modules/settings/HomePhonePage.qml`
- Create: `config/dot/quickshell/modules/settings/HomeFavoriteCard.qml`
- Create: `config/dot/quickshell/modules/settings/AvailableLightRow.qml`
- Modify: `config/dot/quickshell/modules/settings/SettingsSidebar.qml:10-25`
- Modify: `config/dot/quickshell/modules/settings/SettingsShell.qml:65-130`
- Modify: `config/dot/quickshell/modules/settings/qmldir:1-19`
- Modify: `config/dot/quickshell/services/ShellState.qml:94-99`
- Modify: `config/dot/quickshell/shell.qml:274-299`
- Modify: `tests/quickshell/settings-pages-contract.sh:15-28`
- Create: `tests/quickshell/home-phone-settings-contract.sh`

**Interfaces:**
- Consumes: `HomeAssistant.catalog`, `selectedEntities`, `discoveredCount`, phase/stale/error, `refresh()`, `open()`; all `HomePreferences` mutators; `SystemSettings.bluebubblesAvailable` and `openApplication("bluebubbles")`.
- Produces: Settings route `home-phone`, a searchable `availableLights` projection, selected-card drag reorder, inline preference-save Retry, and `bluebubblesAvailable: bool`.
- `HomeFavoriteCard` consumes `favorite`, `sourceName`, `index`, `featured`; emits `aliasCommitted(id, alias)`, `removeRequested(id)`, and `moveRequested(id, targetIndex)`.
- `AvailableLightRow` consumes `entity`; emits `addRequested(id)`.

- [x] **Step 1: Write failing route, component, and privacy-safe page contracts**

Add `home-phone` to the route array immediately after `connectivity`. The focused page contract must assert:

```bash
rg -Fq 'text: "Home & Phone"' config/dot/quickshell/modules/settings/HomePhonePage.qml
rg -Fq 'HomePreferences.setAlias' config/dot/quickshell/modules/settings/HomePhonePage.qml
rg -Fq 'HomePreferences.move' config/dot/quickshell/modules/settings/HomePhonePage.qml
rg -Fq 'HomePreferences.remove' config/dot/quickshell/modules/settings/HomePhonePage.qml
rg -Fq 'HomePreferences.add' config/dot/quickshell/modules/settings/HomePhonePage.qml
rg -Fq 'HomePreferences.retrySave' config/dot/quickshell/modules/settings/HomePhonePage.qml
! rg -i 'token|bearer|api/states' config/dot/quickshell/modules/settings/HomePhonePage.qml
```

Using the ready fixture, route Settings to `home-phone` and assert one tiled `Panama Settings` client. IPC status must report `page == "home-phone"`, `discoveredCount == 7`, and `selectedCount == 7`. The test may read fixture state but must not call preference mutators.

- [x] **Step 2: Run the settings contracts and verify RED**

Run:

```bash
tests/quickshell/settings-pages-contract.sh
tests/quickshell/home-phone-settings-contract.sh
```

Expected: `home-phone` falls back to Home and the new components are missing.

- [x] **Step 3: Add BlueBubbles availability and fixed launch mapping**

Add a dedicated probe process and expose its mutable result through a read-only public property:

```qml
property bool bluebubblesDetected: false
readonly property bool bluebubblesAvailable: root.bluebubblesDetected

Process {
    id: bluebubblesQuery
    command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
    onExited: (exitCode, exitStatus) => root.bluebubblesDetected = exitCode === 0
}
```

Start it from `refresh()` when not running. Extend `openApplication()` with exactly:

```qml
"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]
```

Expose `bluebubblesAvailable` through the existing `settings-system` IPC status for read-only tests. Do not derive this property from KDE Connect and do not construct a shell command string.

- [x] **Step 4: Build the page shell and Home Assistant health card**

Create a `Flickable` page matching existing 34 px horizontal/30 px top insets. Use title `Home & Phone` and subtitle `Choose what appears in Control Center and keep phone continuity close at hand.` The Home Assistant card shows:

- `Connected · N lights discovered` for ready;
- `Last update unavailable · showing saved controls` for degraded;
- `Authentication required` for `authentication-required`;
- `Home Assistant is not configured` for `not-configured`;
- Refresh and Open buttons wired only to `HomeAssistant.refresh()` and `HomeAssistant.open()`.

- [x] **Step 5: Build selected cards with alias editing and drag reordering**

Render `HomeAssistant.selectedEntities` in a two-column `GridView`. Each `HomeFavoriteCard` shows alias in a `TextInput`, source name beneath it, a quiet `Control Center` badge for indexes 0–3, a remove button, and a six-dot drag handle.

The drag handle owns a `DragHandler`; while active the card lifts with a Prism border and `z: 10`. On release, convert the card center into a target grid cell:

```qml
const column = Math.max(0, Math.min(1, Math.floor(centerX / grid.cellWidth)));
const row = Math.max(0, Math.floor(centerY / grid.cellHeight));
root.moveRequested(favorite.id, Math.min(modelCount - 1, row * 2 + column));
```

Commit alias on editing finished, not on every keystroke. Preserve duplicate aliases. An empty trimmed alias displays the current source name in Control Center.

- [x] **Step 6: Build searchable Available lights and persistence failure state**

Define:

```qml
readonly property var availableLights: HomeAssistant.catalog.filter(entity => {
    if (HomePreferences.isSelected(entity.id))
        return false;
    const haystack = (entity.sourceName + " " + entity.id).toLowerCase();
    return root.lightQuery === "" || haystack.includes(root.lightQuery);
})
```

The Available lights card contains a search field and rows showing source name, entity ID, state, and Add. If no favorites are selected, show `Choose lights below to build your Control Center shelf.` If the filtered list is empty, distinguish `All discovered lights are already selected` from `No lights match that search`.

When `HomePreferences.saveError` is non-empty, show one inline amber row with the exact safe message and a Retry button calling `retrySave()`. Do not route this error through global `SystemSettings.lastError`.

- [x] **Step 7: Add Phone continuity and route registration**

Add a final compact card showing Messages, `Opens BlueBubbles`, installed/unavailable status, and an Open button enabled only by `SystemSettings.bluebubblesAvailable`. Wire the sidebar destination between connectivity and desktop, add the loader component and qmldir registrations, and allow `home-phone` in `ShellState.openSettings()`.

Extend settings diagnostics with read-only counts:

```qml
{
    open: ShellState.settingsOpen,
    page: ShellState.settingsPage,
    discoveredCount: HomeAssistant.discoveredCount,
    selectedCount: HomeAssistant.configuredCount
}
```

- [x] **Step 8: Run Settings, system, and persistence contracts**

Run:

```bash
tests/quickshell/settings-system-contract.sh
tests/quickshell/settings-pages-contract.sh
tests/quickshell/home-phone-settings-contract.sh
tests/quickshell/home-preferences-contract.sh
```

Expected: all PASS; Panama Settings remains one normal tiled client, fixture tests leave real Home preferences unchanged, and no BlueBubbles process starts.

- [x] **Step 9: Commit the management UI**

```bash
git add config/dot/quickshell/services/SystemSettings.qml \
  config/dot/quickshell/modules/settings/HomePhonePage.qml \
  config/dot/quickshell/modules/settings/HomeFavoriteCard.qml \
  config/dot/quickshell/modules/settings/AvailableLightRow.qml \
  config/dot/quickshell/modules/settings/SettingsSidebar.qml \
  config/dot/quickshell/modules/settings/SettingsShell.qml \
  config/dot/quickshell/modules/settings/qmldir \
  config/dot/quickshell/services/ShellState.qml \
  config/dot/quickshell/shell.qml \
  tests/quickshell/settings-pages-contract.sh \
  tests/quickshell/home-phone-settings-contract.sh
git commit -m "Add Home and Phone settings"
```

---

### Task 5: Build the Accessory Shelf and Release-Commit Dimmers

**Files:**
- Create: `config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml`
- Modify: `config/dot/quickshell/modules/quicksettings/HomeTile.qml:1-73`
- Modify: `config/dot/quickshell/modules/quicksettings/HomeControls.qml:1-280`
- Modify: `config/dot/quickshell/modules/quicksettings/qmldir`
- Modify: `tests/quickshell/control-center-contract.sh:19-72`

**Interfaces:**
- Consumes: `HomeAssistant.visibleEntities`, `selectedEntities`, `isBusy(id)`, `pendingFor(id)`, `errorFor(id)`, `toggleEntity(id)`, `setBrightness(id, percent)`, phase/stale, refresh/open; `ShellState.openSettings("home-phone")`.
- Produces: `HomeBrightnessSlider.value: int`, `enabled: bool`, `previewChanged(int)`, and `committed(int)`; `HomeTile` emits `powerRequested` and `brightnessRequested(int)`.
- Produces a two-column resting and expanded shelf with slider hit areas that do not bubble power clicks.

- [x] **Step 1: Write failing structure and fixture interaction assertions**

Require `HomeBrightnessSlider.qml` and its qmldir entry. Static assertions must prove:

```bash
rg -Fq 'columns: 2' config/dot/quickshell/modules/quicksettings/HomeControls.qml
rg -Fq 'model: HomeAssistant.visibleEntities' config/dot/quickshell/modules/quicksettings/HomeControls.qml
rg -Fq 'model: HomeAssistant.selectedEntities' config/dot/quickshell/modules/quicksettings/HomeControls.qml
rg -Fq 'onCommitted: value => root.brightnessRequested(value)' config/dot/quickshell/modules/quicksettings/HomeTile.qml
rg -Fq 'ShellState.openSettings("home-phone")' config/dot/quickshell/modules/quicksettings/HomeControls.qml
```

Keep the live panel mapping and exclusive expansion assertions. Add ready-fixture status assertions that resting count is four and expanded selected count is seven.

- [x] **Step 2: Run the Control Center contract and verify RED**

Run:

```bash
tests/quickshell/control-center-contract.sh
```

Expected: the two-column shelf, slider, and Home & Phone management route are absent.

- [x] **Step 3: Implement a slider with local preview and one commit**

Create a focused slider instead of changing shared `ValueSlider.qml`. It accepts integer 0–100 and keeps `previewValue` local while pressed. Pointer press/move emits `previewChanged(previewValue)`; pointer release emits `committed(previewValue)` once. Wheel steps by 5 and commits once per wheel event. External value changes update preview only when not pressed.

Use a 10 px rounded track, amber-to-warm Prism fill, 16 px light knob, and a 16 px effective vertical hit expansion. Expose the hit area only inside the slider component so tile power clicks cannot intercept dimming.

- [x] **Step 4: Rebuild `HomeTile` as the approved larger accessory control**

Use a 124 px minimum height, 14 px radius, 13 px insets, active amber glass, and quiet inactive glass. Layout:

- top row: 28 px bulb power control, state at right;
- middle: alias, elided to one line; confirmed/pending percentage below;
- bottom: full-width `HomeBrightnessSlider`;
- inline error replaces the secondary state line in `Theme.warn` without changing tile height.

The tile body and bulb call `powerRequested()` only when available and not busy. The slider remains enabled only when available, dimmable, and not busy. While dragging, percentage text uses local preview. On failure the service removes pending state, causing the slider and text to bind back to confirmed `entity.brightnessPct`.

- [x] **Step 5: Rebuild `HomeControls` resting, expanded, and setup states**

Resting state uses `Grid { columns: 2; columnSpacing: 8; rowSpacing: 8 }` over `visibleEntities`. Expanded state uses the same two-column tile language over every `selectedEntities` item inside its existing `Section`/scroll boundary. Wire each delegate:

```qml
HomeTile {
    entity: modelData
    busy: HomeAssistant.isBusy(modelData.id)
    pendingBrightness: HomeAssistant.pendingFor(modelData.id)
    errorCode: HomeAssistant.errorFor(modelData.id)
    onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
    onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
}
```

At the expanded list footer add `Manage in Settings`, which closes Control Center and opens `home-phone`. If the initialized selection is empty, replace the shelf with one setup row opening `home-phone`. Preserve loading, authentication, not-configured, stale, Retry, and Open Home Assistant states, but base selected counts on `configuredCount` and discovery copy on `discoveredCount`.

- [x] **Step 6: Run service and Control Center contracts**

Run:

```bash
tests/quickshell/control-center-services-contract.sh
tests/quickshell/control-center-contract.sh
```

Expected: PASS. Opening and expanding Home maps one panel, displays the fixture shelf, and does not invoke the real Home Assistant API.

- [x] **Step 7: Commit the accessory shelf**

```bash
git add config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml \
  config/dot/quickshell/modules/quicksettings/HomeTile.qml \
  config/dot/quickshell/modules/quicksettings/HomeControls.qml \
  config/dot/quickshell/modules/quicksettings/qmldir \
  tests/quickshell/control-center-contract.sh
git commit -m "Build the Home accessory shelf"
```

---

### Task 6: Add Independent BlueBubbles Messages Action

**Files:**
- Modify: `config/dot/quickshell/modules/quicksettings/PhoneControls.qml:1-273`
- Create: `tests/quickshell/phone-messages-contract.sh`
- Modify: `tests/quickshell/control-center-contract.sh:41-72`

**Interfaces:**
- Consumes: `SystemSettings.bluebubblesAvailable`, `SystemSettings.openApplication("bluebubbles")`, and existing KDE Connect capability/reachability/action interfaces.
- Produces: four equal action models where `share`, `clipboard`, and `ring` use KDE Connect support/reachability, while `messages` uses only BlueBubbles availability.

- [x] **Step 1: Write the failing independent-enable contract**

The static contract must require a Messages model, exact application ID invocation, and an enable expression independent of `KdeConnect.phoneReachable`. It must also assert the fixed command array exists in `SystemSettings.qml`. The live read-only portion calls only `settings-system status` and requires `bluebubblesAvailable == true` on this workstation.

Add a guard that fails if the script contains `openApplication` in an executed `qs ipc call`; the test must never launch the client.

- [x] **Step 2: Run the contract and verify RED**

Run:

```bash
tests/quickshell/phone-messages-contract.sh
```

Expected: FAIL because PhoneControls still has three KDE-only actions.

- [x] **Step 3: Split action capability from action enablement**

Build four fixed models:

```qml
readonly property var actionModels: [
    { id: "share", glyph: "\u{F0142}", label: "Send file", available: KdeConnect.supports("share"), enabled: KdeConnect.phoneReachable && !KdeConnect.transferActive },
    { id: "clipboard", glyph: "\u{F014C}", label: "Clipboard", available: KdeConnect.supports("clipboard"), enabled: KdeConnect.phoneReachable && !KdeConnect.transferActive },
    { id: "ring", glyph: "\u{F009A}", label: "Ring", available: KdeConnect.supports("ring"), enabled: KdeConnect.phoneReachable && !KdeConnect.transferActive },
    { id: "messages", glyph: "\u{F0369}", label: "Messages", available: true, enabled: SystemSettings.bluebubblesAvailable }
]
```

Render four equal columns even if a KDE plugin is unavailable; unavailable actions remain visible but disabled so the card does not jump. `invoke("messages")` calls only `SystemSettings.openApplication("bluebubbles")` and closes the Control Center on successful handoff. Keep file dialog, clipboard, and ring paths unchanged.

- [x] **Step 4: Add missing-client copy to Phone details**

When BlueBubbles is absent, append one quiet detail row: `BlueBubbles is not installed` with no action. Keep `Actions become available when the iPhone reconnects` scoped to the three KDE actions so it does not imply Messages requires proximity.

- [x] **Step 5: Run phone and Control Center contracts**

Run:

```bash
tests/quickshell/phone-messages-contract.sh
tests/quickshell/control-center-contract.sh
tests/quickshell/control-center-services-contract.sh
```

Expected: PASS; the offline KDE fixture still exposes an enabled Messages action when BlueBubbles is installed, and no client launches during testing.

- [x] **Step 6: Commit Messages integration**

```bash
git add config/dot/quickshell/modules/quicksettings/PhoneControls.qml \
  tests/quickshell/phone-messages-contract.sh \
  tests/quickshell/control-center-contract.sh
git commit -m "Add BlueBubbles to Phone controls"
```

---

### Task 7: Full Verification, Visual Review, Documentation, and Push

**Files:**
- Modify: `config/dot/hypr/README.md`
- Modify: `docs/superpowers/plans/2026-08-17-home-accessories-customization.md` (mark executed checkboxes)
- Verify: all files changed in Tasks 1–6

**Interfaces:**
- Consumes: completed helper, preferences, service, Settings, Accessory Shelf, and BlueBubbles integration.
- Produces: user-facing operating notes, a clean live shell, visual evidence for all required states, a final verification commit, and synchronized `origin/main`.

- [x] **Step 1: Update durable user documentation**

Document:

- Control Center shows the first four Home favorites and expands to all selected lights;
- `Panama Settings → Home & Phone` manages order and aliases;
- slider release sends the brightness action and power toggle restores Home Assistant's own previous level;
- Messages opens BlueBubbles independently of KDE Connect;
- Home credentials remain in `bash/env` or the legacy GNOME/Secret Service fallback, while favorites live in Quickshell state.

Do not include entity IDs, friendly names, URLs, tokens, or the contents of the user's preference file.

- [x] **Step 2: Run focused automated verification**

Run:

```bash
python3 tests/quickshell/home_assistant_bridge_test.py -v
tests/quickshell/home-assistant-helper-contract.sh
tests/quickshell/home-preferences-contract.sh
tests/quickshell/control-center-services-contract.sh
tests/quickshell/home-phone-settings-contract.sh
tests/quickshell/phone-messages-contract.sh
tests/quickshell/control-center-contract.sh
tests/quickshell/settings-system-contract.sh
tests/quickshell/settings-pages-contract.sh
```

Expected: every command exits 0. The helper contract performs read-only GETs; fixtures perform no real light action; BlueBubbles remains closed unless it was already open.

- [x] **Step 3: Run the complete Quickshell regression suite**

Run:

```bash
for test in tests/quickshell/*contract.sh; do
  printf 'Running %s\n' "$test"
  "$test"
done
```

Expected: every contract prints PASS and exits 0. If a test opens a panel, its trap closes it and restores live service mode.

- [x] **Step 4: Validate the live shell and logs**

Run:

```bash
hyprctl reload
qs ipc call home-assistant reset
qs ipc call kdeconnect reset
qs log -n -t 300 --no-color | rg -i 'qml|homeassistant|homepreferences|bluebubbles|error|warn' || true
```

Expected: Hyprland reports success; Quickshell remains running; there are no new QML construction errors, binding loops, uncaught JavaScript exceptions, credential strings, or rapid process respawns. Investigate any warning produced by changed components before proceeding.

- [x] **Step 5: Capture and inspect approved visual states**

Create `/tmp/panama-home-verification` and capture full-screen PNGs after each fixture/routing setup:

1. `home-assistant fixture ready` + resting Control Center;
2. ready fixture + expanded Home section;
3. `missing-selected` + expanded Home section;
4. `action-error` + resting Control Center;
5. ready fixture + Panama Settings on `home-phone`;
6. offline KDE Connect fixture + Phone section, proving Messages remains available.

Inspect every PNG at original resolution. Confirm two equal shelf columns, no clipped aliases or percentages, stable tile heights, amber slider contrast, quiet inline errors, correct four-item badge treatment in Settings, readable source names, tight Control Center attachment, and four equal Phone actions. Keep captures under `/tmp`; do not commit private Home Assistant names.

- [ ] **Step 6: Return fixtures to live state and perform user-driven action checks**

Reset fixtures and leave the interfaces open for the user. Ask the user to perform these explicit checks because they change external state:

- drag one real light slider and confirm the light changes only on release;
- set it to zero and confirm it turns off;
- toggle it normally and confirm Home Assistant restores its prior brightness;
- rename/reorder one favorite and confirm Control Center updates and survives a Quickshell restart;
- click Messages and confirm BlueBubbles opens.

Do not perform those actions on the user's behalf.

- [x] **Step 7: Commit documentation and plan completion**

```bash
git add config/dot/hypr/README.md \
  docs/superpowers/plans/2026-08-17-home-accessories-customization.md
git commit -m "Document Home accessory customization"
```

- [ ] **Step 8: Verify repository state and push**

Run:

```bash
git status --short --branch
git log --oneline --decorate -8
git push origin main
git status --short --branch
```

Expected: the worktree is clean, the feature commits are visible, push succeeds, and `main` is synchronized with `origin/main`.
