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"
diff --git a/src/components/ha-assist-chat.ts b/src/components/ha-assist-chat.ts
index 1d47313e56..b920767823 100644
--- a/src/components/ha-assist-chat.ts
+++ b/src/components/ha-assist-chat.ts
@@ -659,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-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-markdown.ts b/src/components/ha-markdown.ts
index 04a18ec52b..c0f435ab34 100644
--- a/src/components/ha-markdown.ts
+++ b/src/components/ha-markdown.ts
@@ -74,9 +74,6 @@ export class HaMarkdown extends LitElement {
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;
@@ -84,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 {
@@ -136,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);
}
@@ -143,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/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/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));
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],
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 -
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/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),
],
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"],
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");
+ });
+});