Files
Panama/docs/superpowers/plans/2026-08-17-home-accessories-customization.md

46 KiB
Raw Permalink Blame History

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 1100 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.qmlhome-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.

  • 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:

[
    {
        "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.

  • 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:

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.

  • Step 3: Run the focused unit suite and verify RED

Run:

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.

  • 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:

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.

  • 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 0100 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:

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:

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.

  • 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:

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:

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.

  • Step 7: Commit the helper boundary
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.

  • Step 1: Write the isolated persistence harness and failing restart contract

Create an IPC harness with these methods:

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:

{"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.

  • Step 2: Run the contract and verify RED

Run:

tests/quickshell/home-preferences-contract.sh

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

  • Step 3: Implement the atomic preference singleton

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

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.

  • Step 4: Run the restart contract and inspect the private file shape

Run:

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.

  • Step 5: Commit preference ownership
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.

  • 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:

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.

  • Step 2: Run the service contract and verify RED

Run:

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

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

  • Step 3: Replace configured entities with catalog/preference resolution

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

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:

{
    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.

  • Step 4: Implement per-entity sequential actions

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

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.

  • 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:

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.

  • Step 6: Run service and preference regression contracts

Run:

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.

  • Step 7: Commit service composition
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).

  • 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:

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.

  • Step 2: Run the settings contracts and verify RED

Run:

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.

  • 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:

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:

"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.

  • 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().

  • 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 03, 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:

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.

  • Step 6: Build searchable Available lights and persistence failure state

Define:

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.

  • 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:

{
    open: ShellState.settingsOpen,
    page: ShellState.settingsPage,
    discoveredCount: HomeAssistant.discoveredCount,
    selectedCount: HomeAssistant.configuredCount
}
  • Step 8: Run Settings, system, and persistence contracts

Run:

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.

  • Step 9: Commit the management UI
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.

  • Step 1: Write failing structure and fixture interaction assertions

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

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.

  • Step 2: Run the Control Center contract and verify RED

Run:

tests/quickshell/control-center-contract.sh

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

  • Step 3: Implement a slider with local preview and one commit

Create a focused slider instead of changing shared ValueSlider.qml. It accepts integer 0100 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.

  • 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.

  • 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:

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.

  • Step 6: Run service and Control Center contracts

Run:

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.

  • Step 7: Commit the accessory shelf
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.

  • 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.

  • Step 2: Run the contract and verify RED

Run:

tests/quickshell/phone-messages-contract.sh

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

  • Step 3: Split action capability from action enablement

Build four fixed models:

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.

  • 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.

  • Step 5: Run phone and Control Center contracts

Run:

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.

  • Step 6: Commit Messages integration
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 16

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.

  • 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.

  • Step 2: Run focused automated verification

Run:

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.

  • Step 3: Run the complete Quickshell regression suite

Run:

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.

  • Step 4: Validate the live shell and logs

Run:

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.

  • 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.

  • Step 7: Commit documentation and plan completion
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:

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.