From 94453dfba520b28d5b3cb7baf562057b7e427155 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 9 Dec 2025 04:15:12 -0800 Subject: [PATCH 1/8] Fix markdown card image sizing (#28449) --- src/components/ha-assist-chat.ts | 1 + src/components/ha-markdown.ts | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/ha-assist-chat.ts b/src/components/ha-assist-chat.ts index 1d47313e56..5e19ad4e86 100644 --- a/src/components/ha-assist-chat.ts +++ b/src/components/ha-assist-chat.ts @@ -134,6 +134,7 @@ export class HaAssistChat extends LitElement { })}" breaks cache + assist .content=${message.text} > diff --git a/src/components/ha-markdown.ts b/src/components/ha-markdown.ts index 04a18ec52b..e41d8dae4a 100644 --- a/src/components/ha-markdown.ts +++ b/src/components/ha-markdown.ts @@ -70,13 +70,15 @@ export class HaMarkdown extends LitElement { a { color: var(--markdown-link-color, var(--primary-color)); } + :host([assist]) img { + height: auto; + width: auto; + transition: height 0.2s ease-in-out; + } img { background-color: var(--markdown-image-background-color); border-radius: var(--markdown-image-border-radius); max-width: 100%; - height: auto; - width: auto; - transition: height 0.2s ease-in-out; } p:first-child > img:first-child { vertical-align: top; From f4f45207730d0e1688fbd12083c3da8df44586b6 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:59:35 +0100 Subject: [PATCH 2/8] Fix target picker area in history/activity (#28474) * Add max target picker width for history and activity * Fix target picker area selection in history and activity --- src/components/ha-generic-picker.ts | 5 ++++- src/components/ha-target-picker.ts | 3 --- src/panels/history/ha-panel-history.ts | 1 + src/panels/logbook/ha-panel-logbook.ts | 3 +++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/ha-generic-picker.ts b/src/components/ha-generic-picker.ts index 6f955cb7e2..85117b91d2 100644 --- a/src/components/ha-generic-picker.ts +++ b/src/components/ha-generic-picker.ts @@ -344,7 +344,10 @@ export class HaGenericPicker extends LitElement { wa-popover::part(body) { width: max(var(--body-width), 250px); - max-width: max(var(--body-width), 250px); + max-width: var( + --ha-generic-picker-max-width, + max(var(--body-width), 250px) + ); max-height: 500px; height: 70vh; overflow: hidden; diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 0d77a72695..28894efe76 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -952,10 +952,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { let hasFloor = false; let rtl = false; let showEntityId = false; - if (type === "area" || type === "floor") { - item.id = item[type]?.[`${type}_id`]; - rtl = computeRTL(this.hass); hasFloor = type === "area" && !!(item as FloorComboBoxItem).area?.floor_id; diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index c62e9142b9..09202895ca 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -631,6 +631,7 @@ class HaPanelHistory extends LitElement { :host([virtualize]) { height: 100%; + --ha-generic-picker-max-width: 400px; } .progress-wrapper { diff --git a/src/panels/logbook/ha-panel-logbook.ts b/src/panels/logbook/ha-panel-logbook.ts index 365731df37..1f8d5eb2f4 100644 --- a/src/panels/logbook/ha-panel-logbook.ts +++ b/src/panels/logbook/ha-panel-logbook.ts @@ -303,6 +303,9 @@ export class HaPanelLogbook extends LitElement { return [ haStyle, css` + :host { + --ha-generic-picker-max-width: 400px; + } ha-logbook { height: calc( 100vh - From 6e5853a1c0c69bb4c83fb9a3cd6e65280377b81d Mon Sep 17 00:00:00 2001 From: Silas Krause Date: Thu, 11 Dec 2025 13:10:26 +0100 Subject: [PATCH 3/8] Support legacy table styles in markdown (#28488) * Remove unnecessary assist styles * Fix list styles * Remove table styles for role="presentation" --- src/components/ha-assist-chat.ts | 3 +-- src/components/ha-markdown.ts | 25 ++++++++++++++++--------- src/resources/markdown-worker.ts | 1 + 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/components/ha-assist-chat.ts b/src/components/ha-assist-chat.ts index 5e19ad4e86..b920767823 100644 --- a/src/components/ha-assist-chat.ts +++ b/src/components/ha-assist-chat.ts @@ -134,7 +134,6 @@ export class HaAssistChat extends LitElement { })}" breaks cache - assist .content=${message.text} > @@ -660,7 +659,7 @@ export class HaAssistChat extends LitElement { --markdown-table-border-color: var(--divider-color); --markdown-code-background-color: var(--primary-background-color); --markdown-code-text-color: var(--primary-text-color); - --markdown-list-indent: 1rem; + --markdown-list-indent: 1.15em; &:not(:has(ha-markdown-element)) { min-height: 1lh; min-width: 1lh; diff --git a/src/components/ha-markdown.ts b/src/components/ha-markdown.ts index e41d8dae4a..c0f435ab34 100644 --- a/src/components/ha-markdown.ts +++ b/src/components/ha-markdown.ts @@ -70,11 +70,6 @@ export class HaMarkdown extends LitElement { a { color: var(--markdown-link-color, var(--primary-color)); } - :host([assist]) img { - height: auto; - width: auto; - transition: height 0.2s ease-in-out; - } img { background-color: var(--markdown-image-background-color); border-radius: var(--markdown-image-border-radius); @@ -86,8 +81,7 @@ export class HaMarkdown extends LitElement { p:first-child > img:last-child { vertical-align: top; } - :host > ul, - :host > ol { + ha-markdown-element > :is(ol, ul) { padding-inline-start: var(--markdown-list-indent, revert); } li { @@ -138,6 +132,18 @@ export class HaMarkdown extends LitElement { border-bottom: none; margin: var(--ha-space-4) 0; } + table[role="presentation"] { + --markdown-table-border-collapse: separate; + --markdown-table-border-width: attr(border, 0); + --markdown-table-padding-inline: 0; + --markdown-table-padding-block: 0; + th { + vertical-align: attr(align, center); + } + td { + vertical-align: attr(align, left); + } + } table { border-collapse: var(--markdown-table-border-collapse, collapse); } @@ -145,14 +151,15 @@ export class HaMarkdown extends LitElement { overflow: auto; } th { - text-align: start; + text-align: var(--markdown-table-text-align, start); } td, th { border-width: var(--markdown-table-border-width, 1px); border-style: var(--markdown-table-border-style, solid); border-color: var(--markdown-table-border-color, var(--divider-color)); - padding: 0.25em 0.5em; + padding-inline: var(--markdown-table-padding-inline, 0.5em); + padding-block: var(--markdown-table-padding-block, 0.25em); } blockquote { border-left: 4px solid var(--divider-color); diff --git a/src/resources/markdown-worker.ts b/src/resources/markdown-worker.ts index dfade11123..414457a480 100644 --- a/src/resources/markdown-worker.ts +++ b/src/resources/markdown-worker.ts @@ -19,6 +19,7 @@ const renderMarkdown = async ( if (!whiteListNormal) { whiteListNormal = { ...getDefaultWhiteList(), + table: [...(getDefaultWhiteList().table ?? []), "role"], input: ["type", "disabled", "checked"], "ha-icon": ["icon"], "ha-svg-icon": ["path"], From 06334a039c9fef22385d4b33fe4fa1ad795e7a68 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:48:41 +0100 Subject: [PATCH 4/8] Fix automation add TCA search icons (#28490) Fix automation add TCA seach icons --- .../ha-automation-add-search.ts | 59 +++++++++---------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts index f52c6d0f79..8ba75e3987 100644 --- a/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts @@ -321,40 +321,39 @@ export class HaAutomationAddSearch extends LitElement { > ` : nothing} - ${item.icon - ? html`` - : item.icon_path - ? html`` - : type === "entity" && (item as EntityComboBoxItem).stateObj - ? html` - - ` - : type === "device" && (item as DevicePickerItem).domain + ${(item as AutomationItemComboBoxItem).renderedIcon + ? html`
+ ${(item as AutomationItemComboBoxItem).renderedIcon} +
` + : item.icon + ? html`` + : item.icon_path || type === "area" + ? html`` + : type === "entity" && (item as EntityComboBoxItem).stateObj ? html` - + > ` - : type === "floor" - ? html`` - : type === "area" - ? html`` + .hass=${this.hass} + .domain=${(item as DevicePickerItem).domain!} + brand-fallback + > + ` + : type === "floor" + ? html`` : nothing} ${item.primary} ${item.secondary @@ -784,7 +783,7 @@ export class HaAutomationAddSearch extends LitElement { id: key, primary: name, secondary: description, - iconPath, + icon_path: iconPath, renderedIcon: icon, type, search_labels: [key, name, description], From 7e58cedd492a76f8a40b20b707bf833bb57589ef Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:14:12 +0100 Subject: [PATCH 5/8] Fix ha-toast z-index (#28491) --- src/components/ha-toast.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ha-toast.ts b/src/components/ha-toast.ts index 4f6be82b24..16ff711073 100644 --- a/src/components/ha-toast.ts +++ b/src/components/ha-toast.ts @@ -13,6 +13,7 @@ export class HaToast extends Snackbar { } .mdc-snackbar { + z-index: 10; margin: 8px; right: calc(8px + var(--safe-area-inset-right)); bottom: calc(8px + var(--safe-area-inset-bottom)); From 7817ebe9830215402fd78fd6b898b0ae06b3726a Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:23:37 -0800 Subject: [PATCH 6/8] Home strategy: don't link non-admin to config pages (#28512) --- .../home/home-area-view-strategy.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/panels/lovelace/strategies/home/home-area-view-strategy.ts b/src/panels/lovelace/strategies/home/home-area-view-strategy.ts index fdc61f029c..940abaec9e 100644 --- a/src/panels/lovelace/strategies/home/home-area-view-strategy.ts +++ b/src/panels/lovelace/strategies/home/home-area-view-strategy.ts @@ -41,7 +41,9 @@ const computeHeadingCard = ( action: "navigate", navigation_path, } - : undefined, + : { + action: "none", + }, }) satisfies HeadingCardConfig; @customElement("home-area-view-strategy") @@ -182,7 +184,7 @@ export class HomeAreaViewStrategy extends ReactiveElement { computeHeadingCard( hass.localize("ui.panel.lovelace.strategy.home.scenes"), "mdi:palette", - "/config/scene/dashboard" + hass.user?.is_admin ? "/config/scene/dashboard" : undefined ), ...scenes.map(computeTileCard), ], @@ -285,12 +287,13 @@ export class HomeAreaViewStrategy extends ReactiveElement { { type: "heading", heading: heading, - tap_action: device - ? { - action: "navigate", - navigation_path: `/config/devices/device/${device.id}`, - } - : undefined, + tap_action: + hass.user?.is_admin && device + ? { + action: "navigate", + navigation_path: `/config/devices/device/${device.id}`, + } + : { action: "none" }, badges: [ ...batteryEntities.slice(0, 1).map((e) => ({ entity: e, @@ -334,7 +337,7 @@ export class HomeAreaViewStrategy extends ReactiveElement { computeHeadingCard( hass.localize("ui.panel.lovelace.strategy.home.automations"), "mdi:robot", - "/config/automation/dashboard" + hass.user?.is_admin ? "/config/automation/dashboard" : undefined ), ...automations.map(computeTileCard), ], From 407cb79805eee797180827f81aaed3417f206f9f Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 15 Dec 2025 16:52:59 +0200 Subject: [PATCH 7/8] Fix power sources graph ordering with multiple sources (#28549) --- .../energy/common/energy-chart-options.ts | 37 ++-- .../common/energy-chart-options.test.ts | 199 ++++++++++++++++++ 2 files changed, 222 insertions(+), 14 deletions(-) create mode 100644 test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts diff --git a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts index 0636e4cee3..d49ac755ae 100644 --- a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts +++ b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts @@ -295,32 +295,41 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) { }); } +function getDatapointX(datapoint: NonNullable[0]) { + const item = + datapoint && typeof datapoint === "object" && "value" in datapoint + ? datapoint + : { value: datapoint }; + return Number(item.value?.[0]); +} + export function fillLineGaps(datasets: LineSeriesOption[]) { const buckets = Array.from( new Set( datasets .map((dataset) => - dataset.data!.map((datapoint) => Number(datapoint![0])) + dataset.data!.map((datapoint) => getDatapointX(datapoint)) ) .flat() ) ).sort((a, b) => a - b); - buckets.forEach((bucket, index) => { - for (let i = datasets.length - 1; i >= 0; i--) { - const dataPoint = datasets[i].data![index]; + + datasets.forEach((dataset) => { + const dataMap = new Map(); + dataset.data!.forEach((datapoint) => { const item: LineDataItemOption = - dataPoint && typeof dataPoint === "object" && "value" in dataPoint - ? dataPoint - : ({ value: dataPoint } as LineDataItemOption); - const x = item.value?.[0]; - if (x === undefined) { - continue; + datapoint && typeof datapoint === "object" && "value" in datapoint + ? datapoint + : ({ value: datapoint } as LineDataItemOption); + const x = getDatapointX(datapoint); + if (!Number.isNaN(x)) { + dataMap.set(x, item); } - if (Number(x) !== bucket) { - datasets[i].data?.splice(index, 0, [bucket, 0]); - } - } + }); + + dataset.data = buckets.map((bucket) => dataMap.get(bucket) ?? [bucket, 0]); }); + return datasets; } diff --git a/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts b/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts new file mode 100644 index 0000000000..0f1a7dc53c --- /dev/null +++ b/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts @@ -0,0 +1,199 @@ +import { assert, describe, it } from "vitest"; +import type { LineSeriesOption } from "echarts/charts"; + +import { fillLineGaps } from "../../../../../../src/panels/lovelace/cards/energy/common/energy-chart-options"; + +// Helper to get x value from either [x,y] or {value: [x,y]} format +function getX(item: any): number { + return item?.value?.[0] ?? item?.[0]; +} + +// Helper to get y value from either [x,y] or {value: [x,y]} format +function getY(item: any): number { + return item?.value?.[1] ?? item?.[1]; +} + +describe("fillLineGaps", () => { + it("fills gaps in datasets with missing timestamps", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + [1000, 10], + [3000, 30], + ], + }, + { + type: "line", + data: [ + [1000, 100], + [2000, 200], + [3000, 300], + ], + }, + ]; + + const result = fillLineGaps(datasets); + + // First dataset should have gap at 2000 filled with 0 + assert.equal(result[0].data!.length, 3); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 2000); + assert.equal(getY(result[0].data![1]), 0); + assert.equal(getX(result[0].data![2]), 3000); + assert.equal(getY(result[0].data![2]), 30); + + // Second dataset should be unchanged + assert.equal(result[1].data!.length, 3); + assert.equal(getX(result[1].data![0]), 1000); + assert.equal(getY(result[1].data![0]), 100); + assert.equal(getX(result[1].data![1]), 2000); + assert.equal(getY(result[1].data![1]), 200); + assert.equal(getX(result[1].data![2]), 3000); + assert.equal(getY(result[1].data![2]), 300); + }); + + it("handles unsorted data from multiple sources", () => { + // This is the bug we're fixing: when multiple power sources are combined, + // the data may not be in chronological order + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + [3000, 30], + [1000, 10], + [2000, 20], + ], + }, + ]; + + const result = fillLineGaps(datasets); + + // Data should be sorted by timestamp + assert.equal(result[0].data!.length, 3); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 2000); + assert.equal(getY(result[0].data![1]), 20); + assert.equal(getX(result[0].data![2]), 3000); + assert.equal(getY(result[0].data![2]), 30); + }); + + it("handles multiple datasets with unsorted data", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + [3000, 30], + [1000, 10], + ], + }, + { + type: "line", + data: [ + [2000, 200], + [1000, 100], + [3000, 300], + ], + }, + ]; + + const result = fillLineGaps(datasets); + + // First dataset should be sorted and have gap at 2000 filled + assert.equal(result[0].data!.length, 3); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 2000); + assert.equal(getY(result[0].data![1]), 0); + assert.equal(getX(result[0].data![2]), 3000); + assert.equal(getY(result[0].data![2]), 30); + + // Second dataset should be sorted + assert.equal(result[1].data!.length, 3); + assert.equal(getX(result[1].data![0]), 1000); + assert.equal(getY(result[1].data![0]), 100); + assert.equal(getX(result[1].data![1]), 2000); + assert.equal(getY(result[1].data![1]), 200); + assert.equal(getX(result[1].data![2]), 3000); + assert.equal(getY(result[1].data![2]), 300); + }); + + it("handles data with object format (LineDataItemOption)", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [{ value: [3000, 30] }, { value: [1000, 10] }], + }, + ]; + + const result = fillLineGaps(datasets); + + assert.equal(result[0].data!.length, 2); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 3000); + assert.equal(getY(result[0].data![1]), 30); + }); + + it("returns empty array for empty datasets", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [], + }, + ]; + + const result = fillLineGaps(datasets); + + assert.deepEqual(result[0].data, []); + }); + + it("handles already sorted data with no gaps", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + [1000, 10], + [2000, 20], + [3000, 30], + ], + }, + ]; + + const result = fillLineGaps(datasets); + + assert.equal(result[0].data!.length, 3); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 2000); + assert.equal(getY(result[0].data![1]), 20); + assert.equal(getX(result[0].data![2]), 3000); + assert.equal(getY(result[0].data![2]), 30); + }); + + it("preserves original data item properties", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + { value: [2000, 20], itemStyle: { color: "red" } }, + { value: [1000, 10], itemStyle: { color: "blue" } }, + ], + }, + ]; + + const result = fillLineGaps(datasets); + + // First item should be the one with timestamp 1000 + const firstItem = result[0].data![0] as any; + assert.equal(getX(firstItem), 1000); + assert.equal(firstItem.itemStyle.color, "blue"); + + // Second item should be the one with timestamp 2000 + const secondItem = result[0].data![1] as any; + assert.equal(getX(secondItem), 2000); + assert.equal(secondItem.itemStyle.color, "red"); + }); +}); From d839152fd1e9b433626d83114d23c32dd191e918 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 19 Dec 2025 17:05:16 +0100 Subject: [PATCH 8/8] Bumped version to 20251203.3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4738435b2a..f64ffeaaf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20251203.2" +version = "20251203.3" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend"