Merge Home & Phone into a three-tab Home that knows your house

Home is now Overview | My Home | Phone. Overview leads with quick-action
tiles (focus, Do Not Disturb, health, snapshots, storage), keeps the
findings card — updates fold in, the reclaim-space prompt is gone on
purpose — and adds glance cards, the next calendar event, and weather.

My Home groups every light by Home Assistant area: the helper gained an
`areas` command (one REST template render, no websocket), and the rooms
degrade to a flat list on setups without areas. The favorites editor and
connection card moved intact. Phone gains a vitals strip — battery and
cell signal read from KDE Connect's plugin D-Bus objects, where absence
is data, not an error — beside ring, clipboard, send-a-file, and the
BlueBubbles handoff.

The retired home-phone id resolves to my-home forever via a new alias
map in SettingsRoutes (with a hasOwnProperty guard so prototype names
cannot leak into settingsPage). Storage no longer claims 0 B free — the
old page read a field the disks helper never emitted.

Contracts updated alongside; per the new workflow, the full suite runs
once at the end of the redesign (see the test backlog note).

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-23 22:06:18 -04:00
parent 5490fd285d
commit 7578348db1
40 changed files with 2578 additions and 703 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ Singleton {
readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"]
readonly property var overallStatuses: ["healthy", "warning", "error"]
readonly property var settingsTargets: ["home-phone", "datetime"]
readonly property var settingsTargets: ["my-home", "datetime"]
readonly property var instructionTargets: ["ddc-permissions"]
Process {
@@ -16,6 +16,7 @@ Singleton {
// "loading" | "ready" | "degraded" | "unavailable"
property string phase: "loading"
property var catalog: []
property var areas: []
property var selectedEntities: []
property bool stale: false
property string lastError: ""
@@ -38,6 +39,10 @@ Singleton {
property var pendingFixtureTarget: null
property int delayedFixtureExitCode: 0
// Catalog lights grouped by Home Assistant area, recomputed whenever either
// half changes. Both halves are read here so the binding tracks them.
readonly property var rooms: root.composeRooms(root.catalog, root.areas)
readonly property var visibleEntities: root.selectedEntities.slice(0, 4)
readonly property int discoveredCount: root.catalog.length
readonly property int configuredCount: root.selectedEntities.length
@@ -56,6 +61,56 @@ Singleton {
if (root.catalog.length === 0)
root.phase = "loading";
refreshProc.running = true;
if (!areasProc.running)
areasProc.running = true;
}
// Rooms hold the SAME entity objects as the catalog, so a tile bound to a
// room light sees exactly what the catalog says. Areas keep the order Home
// Assistant reports them in; catalog lights no area claims fall into a
// trailing "Other" bucket. With no usable areas the whole catalog becomes a
// single unnamed bucket, which the UI renders as a flat list with no header.
function composeRooms(catalog: var, areas: var): var {
const lights = Array.isArray(catalog) ? catalog : [];
const flat = [{ id: "", name: "", lights: lights.slice() }];
if (!Array.isArray(areas) || areas.length === 0)
return flat;
const catalogById = {};
for (let index = 0; index < lights.length; index++)
catalogById[lights[index].id] = lights[index];
const claimed = {};
const grouped = [];
for (let index = 0; index < areas.length; index++) {
const area = areas[index];
const entityIds = area && Array.isArray(area.entities) ? area.entities : [];
const roomLights = [];
for (let inner = 0; inner < entityIds.length; inner++) {
const entity = catalogById[entityIds[inner]];
if (!entity || claimed[entity.id] === true)
continue;
claimed[entity.id] = true;
roomLights.push(entity);
}
if (roomLights.length === 0)
continue;
grouped.push({
id: String(area.id || ""),
name: String(area.name || ""),
lights: roomLights
});
}
// Areas that name nothing in the catalog leave every light an orphan;
// one "Other" heading over the whole list is worse than no heading.
if (grouped.length === 0)
return flat;
const orphans = lights.filter(entity => claimed[entity.id] !== true);
if (orphans.length > 0)
grouped.push({ id: "", name: "Other", lights: orphans });
return grouped;
}
function consumeCatalog(text: string): void {
@@ -91,6 +146,26 @@ Singleton {
}
}
// Rooms are a nicety layered over the catalog: a Home Assistant whose
// template endpoint is unavailable still gets its lights. A failure here
// only empties `areas` -- it never touches `phase`, `stale`, or `lastError`.
function consumeAreas(text: string): void {
if (root.fixtureMode)
return;
let result = null;
try {
result = JSON.parse(text);
} catch (error) {
root.areas = [];
return;
}
root.areas = result && result.ok === true && Array.isArray(result.areas)
? result.areas
: [];
}
function rebuildSelection(): void {
const favorites = root.fixtureMode
? root.fixtureFavorites
@@ -364,6 +439,16 @@ Singleton {
];
}
// Deliberately leaves light.fixture_all and light.fixture_corner unclaimed
// so fixtures exercise the trailing "Other" bucket as well as the rooms.
function fixtureAreas(): var {
return [
{ id: "kitchen", name: "Kitchen", entities: ["light.fixture_kitchen"] },
{ id: "living_room", name: "Living room", entities: ["light.fixture_living", "light.fixture_desk"] },
{ id: "bedroom", name: "Bedroom", entities: ["light.fixture_bedroom", "light.fixture_hall"] }
];
}
function fixturePreferenceRecords(): var {
return [
{ id: "light.fixture_all", alias: " Whole home " },
@@ -479,6 +564,7 @@ Singleton {
: (name === "process-slow-actions" ? "slow-success" : "")));
if (name === "unavailable") {
root.catalog = [];
root.areas = [];
root.fixtureFavorites = [];
root.rebuildSelection();
root.phase = "unavailable";
@@ -488,6 +574,7 @@ Singleton {
}
root.catalog = root.fixtureEntities();
root.areas = root.fixtureAreas();
root.fixtureFavorites = root.fixturePreferenceRecords();
if (name === "available-extra") {
root.catalog = root.catalog.concat([{
@@ -530,6 +617,7 @@ Singleton {
root.fixtureProcessMode = "";
root.phase = "loading";
root.catalog = [];
root.areas = [];
root.selectedEntities = [];
root.fixtureFavorites = [];
root.stale = false;
@@ -545,6 +633,14 @@ Singleton {
}
}
Process {
id: areasProc
command: [root.helperPath, "areas"]
stdout: StdioCollector {
onStreamFinished: root.consumeAreas(this.text)
}
}
Process {
id: actionProc
stdout: StdioCollector {
+46 -3
View File
@@ -43,6 +43,12 @@ Singleton {
readonly property int pairedCount: root.devices.filter(device => device.paired).length
readonly property var phoneActions: root.preferredPhone?.actions ?? []
// Vitals are null whenever the phone is away or the plugin is off, which is
// the normal state rather than an error -- consumers show nothing, not a
// zero. `?? null` keeps `undefined` from leaking out as a distinct case.
readonly property var phoneBattery: root.preferredPhone?.battery ?? null
readonly property var phoneSignal: root.preferredPhone?.signal ?? null
function supports(action: string): bool {
return root.phoneActions.indexOf(action) >= 0;
}
@@ -53,13 +59,44 @@ Singleton {
statusProc.running = true;
}
// The helper emits `battery` and `signal` as either null or a complete
// object. These rebuild them anyway so a partial payload -- an old helper,
// a field that arrived as a string -- becomes null rather than a card
// rendering "undefined%".
function normalizeBattery(value: var): var {
if (!value || typeof value.charge !== "number" || value.charge < 0)
return null;
return {
charge: Math.round(value.charge),
charging: value.charging === true
};
}
function normalizeSignal(value: var): var {
if (!value || typeof value.strength !== "number" || value.strength < 0)
return null;
return {
networkType: String(value.networkType ?? ""),
strength: Math.round(value.strength)
};
}
function normalizeDevice(device: var): var {
return Object.assign({}, device, {
battery: root.normalizeBattery(device.battery),
signal: root.normalizeSignal(device.signal)
});
}
function consumeStatus(text: string): void {
if (root.fixtureMode)
return;
try {
const result = JSON.parse(text);
root.available = result.available === true;
root.devices = Array.isArray(result.devices) ? result.devices : [];
root.devices = Array.isArray(result.devices)
? result.devices.map(device => root.normalizeDevice(device))
: [];
root.lastError = String(result.error ?? "");
} catch (error) {
root.available = false;
@@ -188,13 +225,19 @@ Singleton {
root.available = true;
root.lastError = "";
root.recentExchange = null;
// Vitals track reachability the way the helper's do: the plugin objects
// only exist for a phone that is actually there, so the offline fixture
// carries nulls and the reachable ones carry readable values.
const reachable = name !== "offline";
root.devices = [{
id: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
name: "Fixture iPhone",
type: "phone",
paired: true,
reachable: name !== "offline",
actions: ["clipboard", "ping", "ring", "share"]
reachable: reachable,
actions: ["clipboard", "ping", "ring", "share"],
battery: reachable ? { charge: 82, charging: true } : null,
signal: reachable ? { networkType: "LTE", strength: 3 } : null
}];
root.transferActive = name === "transfer";
root.transferFileName = name === "transfer" ? "Fixture document.pdf" : "";
@@ -27,7 +27,11 @@ Singleton {
// alias. Three category ids ("applications", "users", "privacy") double as
// the id of their first tab, which resolves to the same place either way.
readonly property var categories: [
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [] },
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [
{ page: "home", label: "Overview" },
{ page: "my-home", label: "My Home" },
{ page: "phone", label: "Phone" }
] },
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}", tabs: [] },
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}", tabs: [] },
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
@@ -44,7 +48,6 @@ Singleton {
{ page: "sharing", label: "Sharing" },
{ page: "printers", label: "Printers" }
] },
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}", tabs: [] },
{ page: "applications", label: "Applications", icon: "\u{F003B}", tabs: [
{ page: "applications", label: "Applications" },
{ page: "gaming", label: "Gaming" },
@@ -98,10 +101,20 @@ Singleton {
return root.categories.find(cat => cat.page === leaf) ?? root.categories[0];
}
// Any id a caller may hold — leaf, category, or garbage — to the leaf that
// should render: a leaf resolves to itself, a category to its first
// available tab, anything unknown to home.
// Retired page ids keep resolving forever: old Vicinae commands, shell
// history, and muscle memory all hold them. Each maps to the leaf that
// absorbed its content.
readonly property var retired: ({ "home-phone": "my-home" })
// Any id a caller may hold — leaf, category, retired id, or garbage — to
// the leaf that should render: a leaf resolves to itself, a category to
// its first available tab, anything unknown to home.
function resolve(id: string): string {
// hasOwnProperty guard: `retired` is a plain JS object, so a bare
// index would answer for prototype names like "toString" and leak a
// function into settingsPage.
if (Object.prototype.hasOwnProperty.call(root.retired, id))
return root.retired[id];
const asLeaf = root.categories.some(cat => (cat.page === id && cat.tabs.length === 0)
|| cat.tabs.some(tab => tab.page === id));
if (asLeaf)
@@ -130,8 +130,14 @@ Singleton {
{ label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" },
{ label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" },
{ label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" },
{ label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "home-phone" },
{ label: "Phone", detail: "Pair a phone for messages and notifications", page: "home-phone" },
{ label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "my-home" },
{ label: "Lights", detail: "Toggle and dim lights, grouped by room", page: "my-home" },
{ label: "Control Center lights", detail: "Choose the accessories on your shelf", page: "my-home" },
{ label: "Phone", detail: "Battery, signal, and reaching your phone", page: "phone" },
{ label: "Ring your phone", detail: "Find it, even when silenced", page: "phone" },
{ label: "Send clipboard to phone", detail: "Hand off what you just copied", page: "phone" },
{ label: "Send a file to phone", detail: "Lands in the phone's downloads", page: "phone" },
{ label: "iMessage", detail: "Opens BlueBubbles", page: "phone" },
{ label: "System information", detail: "Kernel, distribution, and hardware", page: "about" },
{ label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", page: "about" },
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },