diff --git a/src/components/dashboard/actions.ts b/src/components/dashboard/actions.ts
index 282c3d48b..04874d17b 100644
--- a/src/components/dashboard/actions.ts
+++ b/src/components/dashboard/actions.ts
@@ -20,23 +20,31 @@ import {
} from "../../util/web-serial.js";
import { chipNameToFilterLabel } from "../wizard/wizard-step-board-platforms.js";
-export function editDevice(device: ConfiguredDevice) {
- void navigate(`/device/${encodeURIComponent(device.configuration)}`);
+/** Open the editor. ``section`` deep-links a component section (read from
+ * ``?section=`` on load); ``reveal`` opts into the one-shot ``reveal=1``
+ * intent — show the visual pane even in a YAML-only or mobile layout,
+ * consumed and stripped on arrival so a reload keeps the saved layout. */
+export function editDevice(
+ device: ConfiguredDevice,
+ opts: { section?: string; reveal?: boolean } = {}
+) {
+ const params = new URLSearchParams();
+ if (opts.section) params.set("section", opts.section);
+ if (opts.reveal) params.set("reveal", "1");
+ const query = params.toString();
+ void navigate(
+ `/device/${encodeURIComponent(device.configuration)}${query ? `?${query}` : ""}`
+ );
}
/** Open the editor deep-linked to a component section (e.g. ``api`` for the
- * encryption affordance); the section is read from ``?section=`` on load.
- * ``reveal`` opts into the one-shot ``reveal=1`` intent: show the visual
- * pane even in a YAML-only or mobile layout — consumed and stripped on
- * arrival, so a reload keeps the user's saved layout. */
+ * encryption affordance); see {@link editDevice} for the opts semantics. */
export function editDeviceSection(
device: ConfiguredDevice,
section: string,
opts: { reveal?: boolean } = {}
) {
- const path = `/device/${encodeURIComponent(device.configuration)}`;
- const reveal = opts.reveal ? "&reveal=1" : "";
- void navigate(`${path}?section=${encodeURIComponent(section)}${reveal}`);
+ editDevice(device, { ...opts, section });
}
/**
diff --git a/src/components/dashboard/device-drawer-content.ts b/src/components/dashboard/device-drawer-content.ts
index 0e32421c6..8381394f1 100644
--- a/src/components/dashboard/device-drawer-content.ts
+++ b/src/components/dashboard/device-drawer-content.ts
@@ -45,6 +45,7 @@ import {
import { espHomeStyles } from "../../styles/shared.js";
import { showPendingChanges, showUpdateAvailable } from "../../util/device-sync.js";
import { getEncryptionState } from "../../util/encryption-state.js";
+import { fireEvent } from "../../util/fire-event.js";
import { ReachabilityFollower } from "../../util/reachability-follower.js";
import { registerMdiIcons } from "../../util/register-icons.js";
import { renderReachabilitySection } from "./device-drawer-content/reachability.js";
@@ -186,10 +187,18 @@ export class ESPHomeDeviceDrawerContent extends LitElement {
}
${
migrationAvailable
- ? html`
+ ? html``
+ `
: nothing
}
${apiEnabled ? renderEncryptionBadge(this, d, encState) : nothing}
diff --git a/src/components/dashboard/render-content.ts b/src/components/dashboard/render-content.ts
index 24702f8ad..723c4e1bf 100644
--- a/src/components/dashboard/render-content.ts
+++ b/src/components/dashboard/render-content.ts
@@ -151,6 +151,7 @@ export function renderCardGrid(
?select-mode=${host._selectMode}
?selected=${host._selectedDevices.has(device.configuration)}
@edit-device=${() => editDevice(device)}
+ @open-config-migration=${() => editDevice(device, { reveal: true })}
@open-encryption-settings=${() => editDeviceSection(device, "api", { reveal: true })}
@install-device=${() => host._openInstallMethod(device)}
@update-device=${() => host._openCommand(device, "install")}
@@ -203,6 +204,8 @@ export function renderTable(host: ESPHomePageDashboard): TemplateResult {
@select-all=${(e: CustomEvent) => host._addToSelection(e.detail)}
@deselect-all=${(e: CustomEvent) => host._removeFromSelection(e.detail)}
@edit-device=${(e: CustomEvent) => editDevice(e.detail)}
+ @open-config-migration=${(e: CustomEvent) =>
+ editDevice(e.detail, { reveal: true })}
@open-encryption-settings=${(e: CustomEvent) =>
editDeviceSection(e.detail, "api", { reveal: true })}
@update-device=${(e: CustomEvent) =>
@@ -274,6 +277,10 @@ export function renderDrawer(host: ESPHomePageDashboard): TemplateResult {
host._drawerOpen = false;
editDevice(e.detail);
}}
+ @open-config-migration=${(e: CustomEvent) => {
+ host._drawerOpen = false;
+ editDevice(e.detail, { reveal: true });
+ }}
@open-encryption-settings=${(e: CustomEvent) => {
host._drawerOpen = false;
editDeviceSection(e.detail, "api", { reveal: true });
diff --git a/src/components/dashboard/table-cell-styles.ts b/src/components/dashboard/table-cell-styles.ts
index 8afd6b5f8..fefbff3f6 100644
--- a/src/components/dashboard/table-cell-styles.ts
+++ b/src/components/dashboard/table-cell-styles.ts
@@ -185,6 +185,26 @@ export const tableCellStyles = css`
box-shadow: 0 0 5px color-mix(in srgb, var(--esphome-migration), transparent 50%);
}
+ /* The migration dot deep-links to the editor; reset the native chrome
+ so it matches the passive dots. */
+ button.cell-indicator {
+ position: relative;
+ padding: 0;
+ border: none;
+ cursor: pointer;
+ }
+ /* Invisible hit-area extender — the 8px dot alone is too small a
+ touch target (24px minimum). */
+ button.cell-indicator::before {
+ content: "";
+ position: absolute;
+ inset: -8px;
+ }
+ button.cell-indicator:focus-visible {
+ outline: 2px solid var(--esphome-primary);
+ outline-offset: 2px;
+ }
+
.cell-indicator-queued {
font-size: 14px;
flex-shrink: 0;
diff --git a/src/components/dashboard/table-columns.ts b/src/components/dashboard/table-columns.ts
index e216bd46e..1eea539b3 100644
--- a/src/components/dashboard/table-columns.ts
+++ b/src/components/dashboard/table-columns.ts
@@ -101,6 +101,7 @@ export function createDeviceColumns(
title=${localize(labelKey)}
>`
: nothing;
+ const migrationTitle = localize("dashboard.status_migration_available_action");
return [
{
accessorKey: "status",
@@ -239,15 +240,25 @@ export function createDeviceColumns(
encVisual.actionable ? encVisual.actionTooltipKey : encVisual.tooltipKey
)
: "";
+ const migrationAvailable = row._device.migration_available === true;
return html`
${row.friendly_name || row.name}
${indicatorDot(row.showModified, "modified", "dashboard.status_modified")}
${indicatorDot(row.showUpdate, "update", "dashboard.status_update_available")}
- ${indicatorDot(
- row._device.migration_available === true,
- "migration",
- "dashboard.status_migration_available"
- )}
+ ${
+ !migrationAvailable
+ ? nothing
+ : selectMode
+ ? indicatorDot(true, "migration", "dashboard.status_migration_available")
+ : html``
+ }
${
row.hasQueuedUpdate
? html``
: nothing
}
- ${
- this.migrationAvailable
- ? html`
-
- ${this._localize("dashboard.status_migration_available")}
- `
- : nothing
- }
+ ${renderMigrationDot(this)}
${
this.queuedUpdate
? html``;
}
+export function renderMigrationDot(
+ card: ESPHomeDeviceCard
+): TemplateResult | typeof nothing {
+ if (!card.migrationAvailable) return nothing;
+ // Actionable except while selecting — in select mode the whole card is
+ // one toggle target, same rule as the encryption button below.
+ if (!card.selectMode) {
+ const label = card._localize("dashboard.status_migration_available_action");
+ return html`
+ ${label}`;
+ }
+ const tooltip = card._localize("dashboard.status_migration_available");
+ return html`
+ ${tooltip}`;
+}
+
// Compact view: no lock for encrypted devices, only the attention
// states (plaintext / pending / mismatch) get an icon.
export function renderEncryptionIcon(
diff --git a/src/components/device-card/styles.ts b/src/components/device-card/styles.ts
index dfb577fcd..abc270b53 100644
--- a/src/components/device-card/styles.ts
+++ b/src/components/device-card/styles.ts
@@ -144,6 +144,26 @@ export const deviceCardStyles = [
box-shadow: 0 0 5px color-mix(in srgb, var(--esphome-migration), transparent 50%);
}
+ /* The migration dot is a button that opens the editor; reset the
+ native chrome so it matches the passive dots. */
+ button.indicator-dot {
+ position: relative;
+ padding: 0;
+ border: none;
+ cursor: pointer;
+ }
+ /* Invisible hit-area extender — the 8px dot alone is too small a
+ touch target (24px minimum). */
+ button.indicator-dot::before {
+ content: "";
+ position: absolute;
+ inset: -8px;
+ }
+ button.indicator-dot:focus-visible {
+ outline: 2px solid var(--esphome-primary);
+ outline-offset: 2px;
+ }
+
/* 4-state encryption icon — secure / insecure / pending / mismatch. */
.encryption-icon {
font-size: 14px;
diff --git a/src/components/device/config-migration-notice.ts b/src/components/device/config-migration-notice.ts
index f921486e3..ab5f2bdb4 100644
--- a/src/components/device/config-migration-notice.ts
+++ b/src/components/device/config-migration-notice.ts
@@ -84,6 +84,8 @@ export class ESPHomeConfigMigrationNotice extends LitElement {
css`
:host {
display: block;
+ /* Migration identity color — matches the dashboard dot and badge. */
+ --notice-accent: var(--esphome-migration);
}
:host([hidden]) {
display: none;
diff --git a/src/components/device/notice-banner.styles.ts b/src/components/device/notice-banner.styles.ts
index 8a6593adb..7a55e5714 100644
--- a/src/components/device/notice-banner.styles.ts
+++ b/src/components/device/notice-banner.styles.ts
@@ -1,7 +1,8 @@
/**
- * Shared styles for the inline notice banners shown above a section's form
- * the warning
- * `.notice` box, its `.body` column, and the `.cta` button.
+ * Shared styles for the inline notice banners shown above a section's form:
+ * the `.notice` box, its `.body` column, and the `.cta` button.
+ * Accent color is `--notice-accent`, defaulting to the warning color; set
+ * it on `:host` to retheme (the migration notice sets the migration purple).
*/
import { css } from "lit";
@@ -12,8 +13,13 @@ export const noticeBannerStyles = css`
gap: var(--wa-space-s);
margin-bottom: var(--wa-space-m);
padding: var(--wa-space-s) var(--wa-space-m);
- border: var(--wa-border-width-s) solid var(--esphome-warning, #f59e0b);
- background: color-mix(in srgb, var(--esphome-warning, #f59e0b), transparent 90%);
+ border: var(--wa-border-width-s) solid
+ var(--notice-accent, var(--esphome-warning, #f59e0b));
+ background: color-mix(
+ in srgb,
+ var(--notice-accent, var(--esphome-warning, #f59e0b)),
+ transparent 90%
+ );
border-radius: var(--wa-border-radius-m);
color: var(--wa-color-text-normal);
font-size: var(--wa-font-size-s);
@@ -23,7 +29,7 @@ export const noticeBannerStyles = css`
.notice wa-icon {
flex-shrink: 0;
font-size: 20px;
- color: var(--esphome-warning, #f59e0b);
+ color: var(--notice-accent, var(--esphome-warning, #f59e0b));
}
.body {
diff --git a/src/translations/en.json b/src/translations/en.json
index 243b84e93..a7769eb58 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -361,6 +361,7 @@
"status_modified": "Modified",
"status_update_available": "Update available",
"status_migration_available": "Config migration available",
+ "status_migration_available_action": "Config migration available. Open the editor to update.",
"status_installing": "Installing…",
"status_compiling": "Compiling…",
"status_renaming": "Renaming…",
diff --git a/test/components/dashboard/device-drawer-content-migration.test.ts b/test/components/dashboard/device-drawer-content-migration.test.ts
index 0b6b1b6c2..c67edae0e 100644
--- a/test/components/dashboard/device-drawer-content-migration.test.ts
+++ b/test/components/dashboard/device-drawer-content-migration.test.ts
@@ -13,13 +13,20 @@ import { makeConfiguredDevice } from "../../_make-configured-device.js";
import { mountDrawerContent } from "./_drawer-content.js";
describe("drawer migration badge", () => {
- it("renders with its label when migration_available is set", async () => {
- const { el } = await mountDrawerContent(
- makeConfiguredDevice({ migration_available: true })
+ it("is a labeled button that opens the editor with the device", async () => {
+ const device = makeConfiguredDevice({ migration_available: true });
+ const { el } = await mountDrawerContent(device);
+ const badge = el.shadowRoot!.querySelector(
+ "button.status-badge--migration"
);
- const badge = el.shadowRoot!.querySelector(".status-badge--migration");
expect(badge).not.toBeNull();
expect(badge!.textContent).toContain("dashboard.status_migration_available");
+ let detail: unknown;
+ el.addEventListener("open-config-migration", (e) => {
+ detail = (e as CustomEvent).detail;
+ });
+ badge!.click();
+ expect(detail).toBe(device);
});
it("stays absent by default", async () => {
diff --git a/test/components/dashboard/render-content-migration-wiring.test.ts b/test/components/dashboard/render-content-migration-wiring.test.ts
new file mode 100644
index 000000000..73e3e0fa1
--- /dev/null
+++ b/test/components/dashboard/render-content-migration-wiring.test.ts
@@ -0,0 +1,80 @@
+/**
+ * @vitest-environment happy-dom
+ *
+ * Pins the open-config-migration → editDevice({ reveal: true }) wiring on
+ * all three dashboard surfaces — the reveal intent is what puts the
+ * migrate nudge on screen in a YAML-only or mobile layout.
+ */
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@home-assistant/webawesome/dist/components/icon/icon.js", () => ({}));
+vi.mock("sonner-js", () => ({ default: { success: vi.fn(), error: vi.fn() } }));
+vi.mock("../../../src/util/navigation.js", () => ({ navigate: vi.fn() }));
+
+import { renderInto } from "../../_dom.js";
+import { makeConfiguredDevice } from "../../_make-configured-device.js";
+import type { ConfiguredDevice } from "../../../src/api/types/devices.js";
+import {
+ renderCardGrid,
+ renderDrawer,
+ renderTable,
+} from "../../../src/components/dashboard/render-content.js";
+import { navigate } from "../../../src/util/navigation.js";
+import { makeDashboardHost, makeTableHost } from "./_host.js";
+
+const DEEP_LINK = "/device/kitchen.yaml?reveal=1";
+
+afterEach(() => {
+ vi.mocked(navigate).mockClear();
+});
+
+function dispatchOpen(el: Element, detail?: ConfiguredDevice) {
+ el.dispatchEvent(new CustomEvent("open-config-migration", { detail }));
+}
+
+describe("open-config-migration wiring", () => {
+ it("card grid opens the editor with the reveal intent", () => {
+ const device = makeConfiguredDevice();
+ const host = makeDashboardHost({
+ _devices: [device],
+ _activeJobs: new Map(),
+ _recentJobs: new Map(),
+ _recentlyAdopted: null,
+ _selectMode: false,
+ _selectedDevices: new Set(),
+ });
+ const container = renderInto(renderCardGrid(host, [device]));
+ const card = container.querySelector("esphome-device-card");
+ expect(card).not.toBeNull();
+
+ dispatchOpen(card!);
+ expect(navigate).toHaveBeenCalledWith(DEEP_LINK);
+ });
+
+ it("table opens the event's device with the reveal intent", () => {
+ const device = makeConfiguredDevice();
+ const host = makeTableHost({ _devices: [device], _sortedDevices: [device] });
+ const container = renderInto(renderTable(host));
+ const table = container.querySelector("esphome-device-table");
+ expect(table).not.toBeNull();
+
+ dispatchOpen(table!, device);
+ expect(navigate).toHaveBeenCalledWith(DEEP_LINK);
+ });
+
+ it("drawer closes and opens the event's device with the reveal intent", () => {
+ const device = makeConfiguredDevice();
+ const host = makeDashboardHost({
+ _drawerOpen: true,
+ _drawerDevice: device,
+ _activeJobs: new Map(),
+ });
+ const container = renderInto(renderDrawer(host));
+ const drawer = container.querySelector("esphome-device-drawer");
+ expect(drawer).not.toBeNull();
+
+ dispatchOpen(drawer!, device);
+ expect(navigate).toHaveBeenCalledWith(DEEP_LINK);
+ expect(host._drawerOpen).toBe(false);
+ });
+});
diff --git a/test/components/dashboard/table-columns.test.ts b/test/components/dashboard/table-columns.test.ts
index a8d3441bd..425010b4e 100644
--- a/test/components/dashboard/table-columns.test.ts
+++ b/test/components/dashboard/table-columns.test.ts
@@ -203,8 +203,12 @@ describe("device table busy install/update actions", () => {
});
});
-function renderNameCell(rowOverrides: Partial = {}): TemplateResult {
- const col = columns.find((c) => "accessorKey" in c && c.accessorKey === "name");
+function renderNameCell(
+ rowOverrides: Partial = {},
+ selectMode = false
+): TemplateResult {
+ const cols = selectMode ? createDeviceColumns(identityLocalize, true) : columns;
+ const col = cols.find((c) => "accessorKey" in c && c.accessorKey === "name");
if (!col?.cell || typeof col.cell !== "function") {
throw new Error("no cell renderer for name column");
}
@@ -225,13 +229,42 @@ function renderNameCell(rowOverrides: Partial = {}): TemplateResult {
}
describe("name-cell migration dot", () => {
- it("renders from the raw device flag and hides by default", () => {
- const shown = renderInto(
- renderNameCell({
- _device: { web_port: null, migration_available: true },
- } as unknown as Partial)
+ it("deep-links to the editor and stops the row click", () => {
+ const device = { web_port: null, migration_available: true };
+ const container = renderInto(
+ renderNameCell({ _device: device } as unknown as Partial)
);
- expect(shown.querySelector(".cell-indicator--migration")).not.toBeNull();
+ const btn = container.querySelector(
+ "button.cell-indicator--migration"
+ );
+ expect(btn).not.toBeNull();
+ let detail: unknown;
+ let rowClicked = false;
+ container.addEventListener("open-config-migration", (e) => {
+ detail = (e as CustomEvent).detail;
+ });
+ container.addEventListener("click", () => {
+ rowClicked = true;
+ });
+ btn!.click();
+ expect(detail).toBe(device);
+ expect(rowClicked).toBe(false);
+ });
+
+ it("renders passive while selecting", () => {
+ const container = renderInto(
+ renderNameCell(
+ {
+ _device: { web_port: null, migration_available: true },
+ } as unknown as Partial,
+ true
+ )
+ );
+ expect(container.querySelector("button.cell-indicator--migration")).toBeNull();
+ expect(container.querySelector("span.cell-indicator--migration")).not.toBeNull();
+ });
+
+ it("hides by default", () => {
const hidden = renderInto(renderNameCell());
expect(hidden.querySelector(".cell-indicator--migration")).toBeNull();
});
diff --git a/test/components/device-card-migration-dot.test.ts b/test/components/device-card-migration-dot.test.ts
index 66fd4902f..c93165a7d 100644
--- a/test/components/device-card-migration-dot.test.ts
+++ b/test/components/device-card-migration-dot.test.ts
@@ -2,7 +2,9 @@
* @vitest-environment happy-dom
*
* The migration dot reads the raw migration_available flag — YAML-derived,
- * so unlike the modified / update dots it is never mDNS-gated.
+ * never mDNS-gated — and deep-links to the editor, where the migrate nudge
+ * is the next click. Passive while selecting: the whole card is one toggle
+ * target in select mode.
*/
import { describe, expect, it, vi } from "vitest";
@@ -13,12 +15,32 @@ vi.mock("@home-assistant/webawesome/dist/components/tooltip/tooltip.js", () => (
import { mountDeviceCard as mount } from "./_device-card.js";
describe("device-card migration dot", () => {
- it("shows the dot with its tooltip when migration_available is set", async () => {
+ it("is a button that opens the editor without triggering the card click", async () => {
const el = await mount({ migrationAvailable: true });
- expect(el.shadowRoot!.querySelector(".indicator-dot--migration")).not.toBeNull();
+ const dot = el.shadowRoot!.querySelector(
+ "button.indicator-dot--migration"
+ );
+ expect(dot).not.toBeNull();
expect(
el.shadowRoot!.querySelector("wa-tooltip[for='ind-migration']")?.textContent
- ).toContain("dashboard.status_migration_available");
+ ).toContain("dashboard.status_migration_available_action");
+ let opens = 0;
+ let cardClicks = 0;
+ el.addEventListener("open-config-migration", () => {
+ opens++;
+ });
+ el.addEventListener("card-click", () => {
+ cardClicks++;
+ });
+ dot!.click();
+ expect(opens).toBe(1);
+ expect(cardClicks).toBe(0);
+ });
+
+ it("renders passive while selecting", async () => {
+ const el = await mount({ migrationAvailable: true, selectMode: true });
+ expect(el.shadowRoot!.querySelector("button.indicator-dot--migration")).toBeNull();
+ expect(el.shadowRoot!.querySelector("span.indicator-dot--migration")).not.toBeNull();
});
it("hides the dot by default", async () => {