Compare commits

..

7 Commits

Author SHA1 Message Date
Paul Bottein 2599f97aae Unify back navigation across subpages 2026-08-04 19:06:55 +02:00
Petar Petrov 1b6e150def Remove dead UV_THREADPOOL_SIZE assignment from gulpfile (#53490)
gulpfile.js set process.env.UV_THREADPOOL_SIZE, but gulp's CLI loads the
gulpfile through liftoff, which does async fs work first. libuv has already
sized its threadpool to the default 4 by then, so the assignment never took
effect. Measured through the gulp CLI, effective threadpool size is 3.4 both
with and without the line, and only reaches 6.9 when the variable comes from
the environment.

Hoisting it earlier cannot fix it either, since the pool is sized before any
line of gulpfile.js runs, and setting it for real measurably changes nothing.
Remove it rather than plumb the variable through the build.
2026-08-04 15:52:42 +02:00
Petar Petrov 939b7012e2 Fix wrong month in date picker calendar header (#53453) 2026-08-04 15:37:00 +02:00
Paulus Schoutsen 488d3d9b37 Revert "Show the automation state on mobile in the automations table" (#53493) 2026-08-04 15:25:41 +02:00
Petar Petrov 08dedc415f Compress faster by running zopfli in a worker pool (#53488)
Compress with zopfli in a worker pool

compress-app gzips every build artifact with zopfli, but @gfx/zopfli is
synchronous WASM: it ran on the main thread, pinned a single core and blocked
the event loop for the whole step, so it dominated the production build.

Replace gulp-zopfli-green with an equivalent gulp transform that runs the same
compressor in a pool of worker_threads sized to availableParallelism(). The
compressed output is byte-identical, and @gfx/zopfli is no longer loaded on the
main thread of every gulp invocation.

On a 12-core machine compress-app drops from 6.98 min to 1.27 min, and the full
production build from 8.87 min to 3.32 min.
2026-08-04 15:11:45 +02:00
karwosts 400bf78ce0 Color icons for "on" automations in automation-picker (#53360)
colorize active icons in automation-picker
2026-08-04 15:46:14 +03:00
Przemysław Szypowicz 9b2093125e Fix unreachable remove button on input chips with long labels (#53487)
Truncate long input chip labels

A chip with a long label, like an SSH public key in an add-on config
list option, grew past its container so the trailing remove button
ended up outside the visible area and could not be clicked. Cap the
chip at the container width so the built-in label ellipsis applies,
and show the full value as a native tooltip in the select selector.

Co-authored-by: Przemysław Szypowicz <2733699+pszypowicz@users.noreply.github.com>
2026-08-04 15:42:35 +03:00
67 changed files with 832 additions and 575 deletions
+1 -1
View File
@@ -3,8 +3,8 @@
import { constants } from "node:zlib";
import gulp from "gulp";
import brotli from "gulp-brotli";
import zopfli from "gulp-zopfli-green";
import paths from "../paths.cjs";
import zopfli from "../zopfli.mjs";
const filesGlob = "*.{js,json,css,svg,xml}";
const brotliOptions = {
+18
View File
@@ -0,0 +1,18 @@
// Worker side of the zopfli pool. @gfx/zopfli is a synchronous WASM build, so
// compressing on the main thread blocks the event loop; one instance per worker
// is what makes the work parallel.
import { parentPort } from "node:worker_threads";
import zopfli from "@gfx/zopfli";
parentPort.on("message", ({ contents, options }) => {
zopfli.gzip(contents, options, (error, result) => {
if (error) {
parentPort.postMessage({ error: error.message ?? String(error) });
return;
}
// `result` is a fresh Uint8Array copied out of the WASM heap, so it owns
// its ArrayBuffer and can be transferred instead of cloned.
parentPort.postMessage({ result }, [result.buffer]);
});
});
+196
View File
@@ -0,0 +1,196 @@
// Gulp transform that gzips files with zopfli across a pool of worker threads.
//
// Drop-in replacement for gulp-zopfli-green. That plugin compresses on the main
// thread, and @gfx/zopfli is synchronous WASM, so it pins a single core and
// blocks the event loop for the whole compression step. Running one WASM
// instance per worker parallelises it; the output bytes are unchanged.
import { availableParallelism } from "node:os";
import { Transform } from "node:stream";
import { buffer as readStream } from "node:stream/consumers";
import { Worker } from "node:worker_threads";
const WORKER_URL = new URL("./zopfli-worker.mjs", import.meta.url);
const EXTENSION = ".gz";
// Left empty on purpose: @gfx/zopfli then applies its own defaults, which is
// what gulp-zopfli-green did, so compressed output stays byte-identical.
const ZOPFLI_OPTIONS = {};
const poolSize = () => {
const configured = Number(process.env.ZOPFLI_WORKERS);
return Number.isInteger(configured) && configured > 0
? configured
: availableParallelism();
};
// One job per worker at a time, so the worker itself is the job slot.
const createPool = (size) => {
const live = new Set();
const idle = [];
const waiting = [];
const inFlight = new Map();
// A dead worker must leave the pool, or it gets handed a job that never
// completes and the build hangs instead of failing.
const retire = (worker, error) => {
if (!live.delete(worker)) {
return;
}
const index = idle.indexOf(worker);
if (index !== -1) {
idle.splice(index, 1);
}
const job = inFlight.get(worker);
inFlight.delete(worker);
job?.reject(error);
waiting.shift()?.();
};
const spawn = () => {
const worker = new Worker(WORKER_URL);
live.add(worker);
// Idle workers must not hold the process open. Each job refs its worker for
// as long as it runs, so a pending compression always keeps the event loop
// alive.
worker.unref();
worker.on("message", ({ error, result }) => {
const job = inFlight.get(worker);
if (!job) {
return;
}
inFlight.delete(worker);
worker.unref();
idle.push(worker);
if (error) {
job.reject(new Error(error));
} else {
job.resolve(
Buffer.from(result.buffer, result.byteOffset, result.byteLength)
);
}
waiting.shift()?.();
});
worker.on("error", (error) => retire(worker, error));
worker.on("exit", () =>
retire(worker, new Error("zopfli worker exited unexpectedly"))
);
return worker;
};
return (contents) =>
new Promise((resolve, reject) => {
const start = () => {
const worker = idle.pop() ?? (live.size < size ? spawn() : undefined);
if (!worker) {
waiting.push(start);
return;
}
inFlight.set(worker, { resolve, reject });
worker.ref();
// `contents` is cloned rather than transferred: buffers read by vinyl
// can share a pooled ArrayBuffer with unrelated buffers, and
// transferring would detach those too.
worker.postMessage({ contents, options: ZOPFLI_OPTIONS });
};
start();
});
};
// Shared by every transform this module hands out, so the worker count is a
// property of the process rather than of how many streams happen to run.
let pool;
const sharedPool = () => {
if (!pool) {
const size = poolSize();
pool = { size, compress: createPool(size) };
}
return pool;
};
// through2 and node's Transform both wait for the previous callback before
// handling the next file, which would serialise the pool down to one worker.
// This keeps `limit` files in flight and applies backpressure beyond that.
class ParallelTransform extends Transform {
#limit;
#handle;
#inFlight = 0;
#resume;
#finish;
constructor(limit, handle) {
super({ objectMode: true, highWaterMark: limit });
this.#limit = limit;
this.#handle = handle;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_transform(file, _encoding, callback) {
this.#inFlight += 1;
this.#handle(file)
.then((result) => {
if (result) {
this.push(result);
}
})
.catch((error) => this.destroy(error))
.finally(() => {
this.#inFlight -= 1;
const resume = this.#resume;
this.#resume = undefined;
resume?.();
if (this.#inFlight === 0) {
const finish = this.#finish;
this.#finish = undefined;
finish?.();
}
});
if (this.#inFlight < this.#limit) {
callback();
} else {
this.#resume = callback;
}
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_flush(callback) {
if (this.#inFlight === 0) {
callback();
} else {
this.#finish = callback;
}
}
}
/**
* @param {object} [options]
* @param {number} [options.threshold] Skip files smaller than this many bytes.
*/
export default ({ threshold = 0 } = {}) => {
const { size, compress } = sharedPool();
return new ParallelTransform(size, async (file) => {
if (file.isNull()) {
return file;
}
if (file.isStream()) {
file.contents = await readStream(file.contents);
}
if (threshold && file.contents.length < threshold) {
// Passed through unrenamed and uncompressed, as gulp-zopfli-green did.
return file;
}
file.contents = await compress(file.contents);
file.path += EXTENSION;
return file;
});
};
+29 -1
View File
@@ -111,7 +111,17 @@ export default tseslint.config(
"no-bitwise": "error",
"no-console": "error",
"no-restricted-globals": [2, "event"],
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"no-restricted-syntax": [
"error",
"LabeledStatement",
"WithStatement",
{
selector:
"CallExpression[callee.property.name=/^(push|replace)State$/]",
message:
"Use navigate(), updateHistoryState() or replaceCurrentUrl() from common/navigate. History entries carry the app's own bookkeeping, which a raw pushState/replaceState drops.",
},
],
"wc/no-self-class": "off",
// import-x rules
@@ -222,6 +232,24 @@ export default tseslint.config(
],
},
},
{
// These own history entries themselves: the navigation helpers, the dialog
// stack, the boot paths that run before the app has any state to keep, and
// the tests that fabricate entries to simulate a document load.
files: [
"src/common/navigate.ts",
"src/dialogs/make-dialog-manager.ts",
"src/state/url-sync-mixin.ts",
"src/panels/config/automation/add-automation-element-dialog.ts",
"src/entrypoints/core.ts",
"src/onboarding/**/*.ts",
"cast/**/*.ts",
"test/**/*.ts",
],
rules: {
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
},
},
{
files: ["src/util/recorder-worklet.js"],
languageOptions: {
-3
View File
@@ -1,4 +1 @@
import { availableParallelism } from "node:os";
import "./build-scripts/gulp/index.mjs";
process.env.UV_THREADPOOL_SIZE = availableParallelism();
+1 -1
View File
@@ -103,7 +103,6 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
@@ -145,6 +144,7 @@
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.3",
+23
View File
@@ -294,3 +294,26 @@ export const formatCallyDateRange = (
return `${startDate}/${endDate}`;
};
/**
* August 2021, for calendar days coming out of cally.
*
* cally hands out calendar days as `Date` objects anchored to UTC midnight, and
* formats its own headings in UTC too. Anything derived from a cally event has
* to be formatted the same way: the resolved time zone shifts the day back for
* negative UTC offsets, which becomes a wrong month — and in January a wrong
* year — whenever the day is the 1st.
*/
export const formatCallyMonthYear = (
dateObj: Date,
locale: FrontendLocaleData
) => formatCallyMonthYearMem(locale.language).format(dateObj);
const formatCallyMonthYearMem = memoizeOne(
(language: string) =>
new Intl.DateTimeFormat(language, {
month: "long",
year: "numeric",
timeZone: "UTC",
})
);
+2 -1
View File
@@ -1,8 +1,9 @@
import type { ReactiveElement } from "lit";
import { updateHistoryState } from "../navigate";
import { throttle } from "../util/throttle";
const throttleReplaceState = throttle((value) => {
history.replaceState({ scrollPosition: value }, "");
updateHistoryState({ scrollPosition: value });
}, 300);
export function restoreScroll(selector: string) {
+59 -39
View File
@@ -1,6 +1,7 @@
import { closeAllDialogs } from "../dialogs/make-dialog-manager";
import { fireEvent } from "./dom/fire_event";
import { mainWindow } from "./dom/get_main_window";
import { currentPath } from "./url/current-path";
declare global {
// for fire event
@@ -17,6 +18,26 @@ export interface NavigateOptions {
// max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500;
/**
* Merge into the current history entry's state, keeping what is already there.
* Entries carry the app's own bookkeeping (`from`, `root`, dialog state), so
* they must never be replaced wholesale.
*/
export const updateHistoryState = (patch: Record<string, unknown>) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, ...patch },
""
);
};
/**
* Rewrite the URL of the current history entry without navigating and without
* touching its state. For query parameter cleanup.
*/
export const replaceCurrentUrl = (url: string) => {
mainWindow.history.replaceState(mainWindow.history.state, "", url);
};
/**
* Stash a destination URL in the current history entry's state. If the page
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
@@ -24,10 +45,7 @@ const DIALOG_WAIT_TIMEOUT = 500;
* The current URL is not changed.
*/
export const setRefreshUrl = (path: string) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, refreshUrl: path },
""
);
updateHistoryState({ refreshUrl: path });
};
/**
@@ -63,37 +81,32 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
}
const replace = options?.replace || false;
if (__DEMO__) {
if (!path.includes("#")) {
// The demo routes with the hash instead of the pathname. Resolve the
// path like the browser would do for pushState, and keep the query
// parameters in the URL query instead of inside the hash.
const url = new URL(
path,
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
);
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
}
if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root
? { root: true }
: (options?.data ?? null),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
} else if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root ? { root: true } : (options?.data ?? null),
if (__DEMO__ && !path.includes("#")) {
// The demo routes with the hash instead of the pathname. Resolve the
// path like the browser would do for pushState, and keep the query
// parameters in the URL query instead of inside the hash.
const url = new URL(
path,
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
);
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
}
const { history } = mainWindow;
if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const state = root ? { root: true } : (options?.data ?? null);
history.replaceState(
from === undefined ? state : { ...state, from },
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
history.pushState({ ...options?.data, from: currentPath() }, "", path);
}
fireEvent(mainWindow, "location-changed", {
replace,
});
@@ -101,8 +114,17 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
};
/**
* Navigate back in history, with fallback to a default path if no history exists.
* This prevents a user from getting stuck when they navigate directly to a page with no history.
* Whether the previous history entry is a page this app navigated away from.
* `history.length` cannot answer this: a login redirect goes through
* `location.assign`, which leaves /auth/authorize right behind the requested
* page, and going back there would bounce the user out of the app.
*/
export const canGoBack = (): boolean =>
mainWindow.history.state?.from !== undefined;
/**
* Navigate back to the page we came from, falling back to a path when the
* previous entry is not ours (deep link, login redirect, fresh tab).
*/
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
@@ -110,14 +132,12 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
return;
}
// Check if we have history to go back to
const { history } = mainWindow;
if (history.length > 1) {
history.back();
// Read after closing dialogs: their history entries are popped by then, so
// this is the state of the page entry.
if (canGoBack()) {
mainWindow.history.back();
return;
}
// No history available, navigate to fallback path
const fallback = fallbackPath || "/";
navigate(fallback, { replace: true });
navigate(fallbackPath || "/", { replace: true });
};
+10
View File
@@ -0,0 +1,10 @@
import { mainWindow } from "../dom/get_main_window";
/**
* The path of the page currently shown by the app. The demo routes with the
* hash instead of the pathname, see navigate().
*/
export const currentPath = (): string =>
__DEMO__
? mainWindow.location.hash.substring(1)
: mainWindow.location.pathname;
+1
View File
@@ -15,6 +15,7 @@ export class HaInputChip extends InputChip {
styles,
css`
:host {
max-width: 100%;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--primary-text-color);
+21 -37
View File
@@ -9,8 +9,8 @@ import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import {
formatCallyDateRange,
formatDateMonth,
formatDateYear,
formatCallyMonthYear,
formatDateMonthYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { transform } from "../../common/decorators/transform";
@@ -60,13 +60,16 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
})
private _hassConfig!: HassConfig;
/** used to show month in calendar-range header */
@state() private _pickerMonth?: string;
/** used to show month and year in calendar-range header */
@state() private _pickerMonthYear?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in calendar-range */
/**
* used for today to navigate focus in calendar-range
*
* Always mirrors the day calendar-range has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected range and page the calendar away from where the user is.
*/
@state() private _focusDate?: string;
@state() private _dateValue?: string;
@@ -92,12 +95,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._hassConfig
)
: undefined;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -167,9 +165,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<span class="month-year">${this._pickerMonthYear}</span>
<ha-icon-button
@click=${this._focusToday}
.path=${mdiCalendarToday}
@@ -233,12 +229,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -311,19 +302,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
});
}
private _focusChanged(ev: HASSDomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
private _focusChanged(
ev: HASSDomEvent<Date> &
HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = ev.target.focusedDate;
}
private _handleChange(
@@ -331,7 +315,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
) {
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = undefined;
this._focusDate = dateElement.focusedDate;
}
private _clickDateRangeChip(
@@ -6,7 +6,8 @@ import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import {
formatDateMonth,
formatCallyMonthYear,
formatDateMonthYear,
formatDateShort,
formatDateYear,
formatISODateOnly,
@@ -60,13 +61,16 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
dateString: string;
};
/** used to show month in calendar-date header */
@state() private _pickerMonth?: string;
/** used to show month and year in calendar-date header */
@state() private _pickerMonthYear?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in cally-calendar-date */
/**
* used for today to navigate focus in cally-calendar-date
*
* Always mirrors the day calendar-date has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected value and page the calendar away from where the user is.
*/
@state() private _focusDate?: string;
public connectedCallback() {
@@ -75,12 +79,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (this.params) {
const date = valueToDate(this.params.value);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -88,7 +87,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
this._value = this.params.value
? {
year: this._pickerYear,
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
dateString: formatISODateOnly(
date,
@@ -144,9 +143,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<span class="month-year">${this._pickerMonthYear}</span>
<ha-icon-button
@click=${this._setToday}
.path=${mdiCalendarToday}
@@ -189,12 +186,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (setFocusDay) {
this._focusDate = this._value.dateString;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -202,19 +194,11 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
}
}
private _focusChanged(ev: HASSDomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
private _focusChanged(
ev: HASSDomEvent<Date> & HASSDomTargetEvent<CalendarDate>
) {
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = ev.target.focusedDate;
}
private _clear() {
+6 -5
View File
@@ -53,10 +53,8 @@ export class HaControlNumberButton extends LitElement {
});
private _boundedValue(value: number) {
// Clamp after snapping: when the step does not divide the range evenly,
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
const stepped = Math.round(value / this._step) * this._step;
return conditionalClamp(stepped, this.min, this.max);
const clamped = conditionalClamp(value, this.min, this.max);
return Math.round(clamped / this._step) * this._step;
}
private get _step() {
@@ -69,7 +67,10 @@ export class HaControlNumberButton extends LitElement {
private get _tenPercentStep() {
if (this.max == null || this.min == null) return this._step;
return Math.max((this.max - this.min) / 10, this._step);
const range = this.max - this.min / 10;
if (range <= this._step) return this._step;
return Math.max(range / 10);
}
private _handlePlusButton() {
+7 -5
View File
@@ -115,9 +115,7 @@ export class HaControlSlider extends LitElement {
}
steppedValue(value: number) {
// Clamp after snapping: when the step does not divide the range evenly,
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
return this.boundedValue(Math.round(value / this.step) * this.step);
return Math.round(value / this.step) * this.step;
}
private _displayedValue(value: number) {
@@ -256,9 +254,13 @@ export class HaControlSlider extends LitElement {
} else if (e.code === "End") {
this.value = this.max;
} else if (e.code === "PageUp") {
this.value = this.steppedValue((this.value ?? 0) + this._tenPercentStep);
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) + this._tenPercentStep)
);
} else if (e.code === "PageDown") {
this.value = this.steppedValue((this.value ?? 0) - this._tenPercentStep);
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) - this._tenPercentStep)
);
} else {
const isRtl = mainWindow.document.dir === "rtl";
let multiplier = 1;
@@ -187,6 +187,7 @@ export class HaSelectSelector extends LitElement {
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
.title=${label}
selected
>
${
+6 -10
View File
@@ -40,7 +40,7 @@ import {
getEntityEntryContext,
} from "../../common/entity/context/get_entity_context";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import { navigate } from "../../common/navigate";
import { navigate, updateHistoryState } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -268,16 +268,12 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _setView(view: MoreInfoView) {
history.replaceState(
{
...history.state,
dialogParams: {
...history.state?.dialogParams,
view,
},
updateHistoryState({
dialogParams: {
...history.state?.dialogParams,
view,
},
""
);
});
this._currView = view;
}
+19 -14
View File
@@ -4,6 +4,7 @@ import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import "../components/ha-icon-button-arrow-prev";
@@ -37,19 +38,14 @@ class HassSubpage extends LitElement {
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar-content">
${
this.mainPage || history.state?.root
this.mainPage || (!backPath && history.state?.root)
? html`<ha-menu-button></ha-menu-button>`
: backPath
? html`
<ha-icon-button-arrow-prev
href=${backPath}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
<div class="main-title">
@@ -79,12 +75,21 @@ class HassSubpage extends LitElement {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
private _backTapped(): void {
private _backTapped(ev: MouseEvent): void {
const backPath = sanitizeNavigationPath(this.backPath);
// Middle click, ctrl and cmd open the parent in a new tab, let the anchor
// handle those.
if (backPath && !isNavigationClick(ev)) {
return;
}
if (this.backCallback) {
this.backCallback();
return;
}
goBack();
goBack(backPath);
}
static get styles(): CSSResultGroup {
+17 -13
View File
@@ -175,17 +175,12 @@ export class HassTabsSubpage extends LitElement {
${
this.mainPage || (!backPath && history.state?.root)
? html`<ha-menu-button></ha-menu-button>`
: backPath
? html`
<ha-icon-button-arrow-prev
.href=${backPath}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
${
this._narrow || !this.showTabs
@@ -246,12 +241,21 @@ export class HassTabsSubpage extends LitElement {
this._content.focus({ preventScroll: true });
}
private _backTapped(): void {
private _backTapped(ev: MouseEvent): void {
const backPath = sanitizeNavigationPath(this.backPath);
// Middle click, ctrl and cmd open the parent in a new tab, let the anchor
// handle those.
if (backPath && !isNavigationClick(ev)) {
return;
}
if (this.backCallback) {
this.backCallback();
return;
}
goBack();
goBack(backPath);
}
private _isActiveTabPath(tabPath: string, currentPath: string): boolean {
@@ -644,6 +644,7 @@ class HaConfigAreaPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/areas/dashboard"
.header=${html`${
area.icon
? html`<ha-icon
@@ -86,8 +86,6 @@ export class HaConfigAreasDashboard extends LitElement {
@state() private _hierarchy?: AreasFloorHierarchy;
private _searchParms = new URLSearchParams(window.location.search);
private _blockHierarchyUpdate = false;
private _blockHierarchyUpdateTimeout?: number;
@@ -168,9 +166,7 @@ export class HaConfigAreasDashboard extends LitElement {
<hass-tabs-subpage
.hass=${this.hass}
.isWide=${this.isWide}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.tabs=${configSections.areas}
.route=${this.route}
has-fab
@@ -317,7 +317,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
color:
automation.state === UNAVAILABLE
? "var(--error-color)"
: "unset",
: automation.state === "on"
? "var(--state-active-color)"
: "unset",
})}
></ha-state-icon>`,
},
@@ -346,19 +348,17 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) =>
narrow
? automation.formatted_state
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -443,9 +443,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
id="entity_id"
.route=${this.route}
.tabs=${configSections.automations}
@@ -14,7 +14,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
@@ -110,6 +110,7 @@ export class HaAutomationTrace extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/automation/dashboard"
.header=${title}
.scrollable=${this.narrow}
>
@@ -452,11 +453,7 @@ export class HaAutomationTrace extends LitElement {
if (runId) {
const params = new URLSearchParams(location.search);
params.delete("run_id");
history.replaceState(
null,
"",
`${location.pathname}?${params.toString()}`
);
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
}
await showAlertDialog(this, {
@@ -11,6 +11,7 @@ import { property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../../common/navigate";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import {
extractSearchParam,
@@ -171,11 +172,7 @@ export const ManualEditorMixin = <TConfig>(
}
protected clearParam(param: string) {
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
}
protected async openSidebar(ev: CustomEvent<SidebarConfig>) {
@@ -74,8 +74,6 @@ class HaConfigBackupOverview extends LitElement {
@state() private _config?: BackupConfig;
private _searchParms = new URLSearchParams(window.location.search);
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has("config") && !this._config) {
@@ -206,9 +204,7 @@ class HaConfigBackupOverview extends LitElement {
return html`
<hass-subpage
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.backup.overview.header")}
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../../common/navigate";
import { debounce } from "../../../common/util/debounce";
import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-alert";
@@ -118,7 +119,7 @@ class HaConfigBackupSettings extends LitElement {
}
private _clearHash() {
history.replaceState(null, "", window.location.pathname);
replaceCurrentUrl(window.location.pathname);
}
protected render() {
@@ -21,6 +21,7 @@ export class CloudForgotPassword extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.title"
)}
@@ -45,6 +45,7 @@ export class CloudLoginPanel extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
header="Home Assistant Cloud"
>
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
@@ -38,6 +38,7 @@ export class CloudRegister extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
>
<div class="content">
@@ -66,8 +66,6 @@ class HaConfigSectionUpdates extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _showSkipped = false;
@state() private _supervisorInfo?: HassioSupervisorInfo;
@@ -155,9 +153,7 @@ class HaConfigSectionUpdates extends LitElement {
return html`
<hass-subpage
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.updates.caption")}
@@ -986,6 +986,7 @@ export class HaConfigDevicePage extends LitElement {
return html`<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/devices/dashboard"
.header=${deviceName}
>
<ha-tooltip for="edit-settings-button" slot="toolbar-icon">
@@ -23,7 +23,7 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import { navigate, updateHistoryState } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
@@ -778,9 +778,7 @@ export class HaConfigDeviceDashboard extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.tabs=${configSections.devices}
.route=${this.route}
.searchLabel=${this.hass.localize(
@@ -1043,7 +1041,7 @@ export class HaConfigDeviceDashboard extends LitElement {
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
history.replaceState({ filter: this._filter }, "");
updateHistoryState({ filter: this._filter });
}
private _addDevice() {
+1 -7
View File
@@ -78,8 +78,6 @@ class HaConfigEnergy extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _info?: EnergyInfo;
@state() private _preferences?: EnergyPreferences;
@@ -126,11 +124,7 @@ class HaConfigEnergy extends LitElement {
return html`
<hass-tabs-subpage
.hass=${this.hass}
.backPath=${
this._searchParms.has("historyBack")
? undefined
: "/config/lovelace/dashboards"
}
back-path="/config/lovelace/dashboards"
.route=${this.route}
.tabs=${TABS}
>
@@ -39,6 +39,7 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { updateHistoryState } from "../../../common/navigate";
import { slugify } from "../../../common/string/slugify";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
@@ -810,9 +811,7 @@ export class HaConfigEntities extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
.columns=${this._columns(this.hass.localize, filteredEntities)}
@@ -1246,7 +1245,7 @@ export class HaConfigEntities extends LitElement {
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
history.replaceState({ filter: this._filter }, "");
updateHistoryState({ filter: this._filter });
}
private _handleSelectionChanged(
@@ -644,9 +644,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
.searchLabel=${this.hass.localize(
@@ -373,7 +373,11 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
: this._manifest?.documentation;
return html`
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/integrations/dashboard"
>
${
documentationLink
? html`
@@ -13,7 +13,7 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import { navigate, updateHistoryState } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { extractSearchParam } from "../../../common/url/search-params";
import { nextRender } from "../../../common/util/render-status";
@@ -158,8 +158,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
window.location.hash.substring(1)
);
@state() private _searchParams = new URLSearchParams(window.location.search);
@state() private _filter: string = history.state?.filter || "";
@state() private _logInfos?: Record<string, IntegrationLogInfo>;
@@ -503,9 +501,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
return html`
<hass-tabs-subpage
.hass=${this.hass}
.backPath=${
this._searchParams.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
has-fab
@@ -862,7 +858,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
private _handleSearchChange(ev: InputEvent) {
this._filter = (ev.target as HaInputSearch).value ?? "";
history.replaceState({ filter: this._filter }, "");
updateHistoryState({ filter: this._filter });
}
private async _highlightEntry() {
@@ -95,6 +95,7 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config/integrations/integration/dhcp"
.columns=${this._columns(this.hass.localize)}
.data=${this._dataWithIds(this._data)}
.noDataText=${this.hass.localize(
@@ -61,7 +61,11 @@ export class MQTTConfigPanel extends LitElement {
protected render(): TemplateResult {
return html`
<hass-subpage .narrow=${this.narrow} .hass=${this.hass}>
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/integrations/integration/mqtt"
>
<div class="content">
<ha-card
.header=${this.hass.localize("ui.panel.config.mqtt.settings_title")}
@@ -99,6 +99,7 @@ export class SSDPConfigPanel extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config/integrations/integration/ssdp"
.columns=${this._columns(this.hass.localize)}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
@@ -106,6 +106,7 @@ export class ZeroconfConfigPanel extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config/integrations/integration/zeroconf"
.columns=${this._columns(this.hass.localize)}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
@@ -76,6 +76,7 @@ class ZHAAddDevicesPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/zha/dashboard"
.header=${this.hass.localize("ui.panel.config.zha.add_device")}
>
<ha-button
@@ -55,6 +55,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/zha/dashboard"
.header=${this.hass.localize(
"ui.panel.config.zha.visualization.header"
)}
@@ -34,7 +34,12 @@ class ZWaveJSConfigEntryPicker extends LitElement {
if (this._configEntries.length === 0) {
return html`
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
<hass-subpage
header="Z-Wave"
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/integrations/integration/zwave_js"
>
<div class="content">
<ha-card>
<div class="card-content">
@@ -51,7 +56,12 @@ class ZWaveJSConfigEntryPicker extends LitElement {
}
return html`
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
<hass-subpage
header="Z-Wave"
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/integrations/integration/zwave_js"
>
<div class="content">
<ha-card
.header=${this.hass.localize(
@@ -175,6 +175,7 @@ export class HaConfigLovelaceResources extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config/lovelace/dashboards"
.tabs=${lovelaceResourcesTabs}
.columns=${this._columns(this.hass.language, this.hass.localize)}
.data=${this._resources}
@@ -32,8 +32,6 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
@state() private _repairsIssues: RepairsIssue[] = [];
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _showIgnored = false;
private _getFilteredIssues = memoizeOne(
@@ -77,9 +75,7 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
return html`
<hass-subpage
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.repairs.caption")}
@@ -459,9 +459,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.automations}
.searchLabel=${this.hass.localize(
+1 -3
View File
@@ -430,9 +430,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.automations}
.searchLabel=${this.hass.localize(
+3 -6
View File
@@ -14,7 +14,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -106,6 +106,7 @@ export class HaScriptTrace extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/script/dashboard"
.header=${title}
.scrollable=${this.narrow}
>
@@ -433,11 +434,7 @@ export class HaScriptTrace extends LitElement {
if (runId) {
const params = new URLSearchParams(location.search);
params.delete("run_id");
history.replaceState(
null,
"",
`${location.pathname}?${params.toString()}`
);
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
}
await showAlertDialog(this, {
@@ -128,6 +128,7 @@ class AssistDevicesPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/voice-assistants/assistants"
.header=${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.devices.title"
)}
@@ -51,6 +51,7 @@ export class AssistPipelineDebug extends LitElement {
return html`<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/voice-assistants/debug"
.header=${this.hass.localize(
"ui.panel.config.voice_assistants.debug.header"
)}
@@ -60,6 +60,7 @@ export class AssistPipelineRunDebug extends LitElement {
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/voice-assistants/assistants"
.header=${this.hass.localize(
"ui.panel.config.voice_assistants.debug.pipeline.header"
)}
@@ -29,8 +29,6 @@ export class HaConfigVoiceAssistantsAssistants extends LitElement {
@property({ attribute: false }) public route!: Route;
private _searchParms = new URLSearchParams(window.location.search);
protected render() {
if (!this.hass) {
return html`<hass-loading-screen></hass-loading-screen>`;
@@ -39,9 +37,7 @@ export class HaConfigVoiceAssistantsAssistants extends LitElement {
return html`
<hass-tabs-subpage
.hass=${this.hass}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${voiceAssistantTabs}
>
@@ -492,9 +492,7 @@ export class VoiceAssistantsExpose extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${voiceAssistantTabs}
.columns=${this._columns(
+1 -5
View File
@@ -57,8 +57,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _storageItems?: Zone[];
@state() private _stateItems?: HassEntity[];
@@ -248,9 +246,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
<hass-tabs-subpage
.hass=${this.hass}
.route=${this.route}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.tabs=${configSections.areas}
has-fab
>
+2 -4
View File
@@ -4,7 +4,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { navigate } from "../../common/navigate";
import { navigate, replaceCurrentUrl } from "../../common/navigate";
import type { LocalizeFunc } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
@@ -509,9 +509,7 @@ export class LovelacePanel extends LitElement {
};
if ("editMode" in props) {
window.history.replaceState(
null,
"",
replaceCurrentUrl(
constructUrlCurrentPath(
props.editMode
? addSearchParam({ edit: "1" })
+5 -21
View File
@@ -28,7 +28,7 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import { goBack, navigate } from "../../common/navigate";
import { goBack, navigate, replaceCurrentUrl } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
@@ -677,11 +677,7 @@ class HUIRoot extends LitElement {
);
private _clearParam(param: string) {
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
}
protected firstUpdated(changedProps: PropertyValues<this>) {
@@ -894,22 +890,10 @@ class HUIRoot extends LitElement {
private _goBack(): void {
const views = this.lovelace?.config.views ?? [];
const curViewConfig =
typeof this._curView === "number" ? views[this._curView] : undefined;
// Falling back to the first view only makes sense when it is a real view.
const fallbackPath = views[0]?.subview ? undefined : this.route?.prefix;
const backPath = sanitizeNavigationPath(
curViewConfig?.back_path ?? this.backPath
);
if (backPath) {
navigate(backPath, { replace: true });
} else if (history.length > 1) {
goBack();
} else if (!views[0].subview) {
navigate(this.route!.prefix, { replace: true });
} else {
navigate("/");
}
goBack(this._backPath ?? fallbackPath);
}
private _handleBackClick(ev: MouseEvent): void {
@@ -9,6 +9,7 @@ import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { clamp } from "../../../common/number/clamp";
import { updateHistoryState } from "../../../common/navigate";
import "../../../components/ha-icon-button";
import "../../../components/ha-ripple";
import "../../../components/ha-sortable";
@@ -494,10 +495,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
this._sidebarTabActive = !this._sidebarTabActive;
// Add sidebar state to history
window.history.replaceState(
{ ...window.history.state, sidebar: this._sidebarTabActive },
""
);
updateHistoryState({ sidebar: this._sidebarTabActive });
// Restore scroll position after view updates
this.updateComplete.then(() => {
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../common/navigate";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-button";
import "../../components/ha-card";
@@ -90,7 +91,7 @@ class HaProfileSectionGeneral extends LitElement {
}
private _clearHash() {
history.replaceState(null, "", window.location.pathname);
replaceCurrentUrl(window.location.pathname);
}
protected render(): TemplateResult {
+2 -4
View File
@@ -2,6 +2,7 @@
import type { ReactiveElement, PropertyValues } from "lit";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { updateHistoryState } from "../common/navigate";
import { closeLastDialog } from "../dialogs/make-dialog-manager";
import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
import type { Constructor } from "../types";
@@ -20,10 +21,7 @@ export const urlSyncMixin = <
public connectedCallback(): void {
super.connectedCallback();
if (mainWindow.history.length === 1) {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, root: true },
""
);
updateHistoryState({ root: true });
}
mainWindow.addEventListener("popstate", this._popstateChangeListener);
}
+152
View File
@@ -0,0 +1,152 @@
/**
* @vitest-environment node
*/
import { Buffer } from "node:buffer";
import process from "node:process";
import { Readable } from "node:stream";
import gfxZopfli from "@gfx/zopfli";
import { describe, expect, it } from "vitest";
// Keep the pool small; it is created once, on the first factory call.
process.env.ZOPFLI_WORKERS = "2";
const { default: zopfli } = await import("../../build-scripts/zopfli.mjs");
const THRESHOLD = 150;
// Stand-in for the vinyl files gulp.src yields in buffer mode.
const file = (path, contents) => ({
path,
contents,
isNull() {
return this.contents === null;
},
isStream() {
return this.contents instanceof Readable;
},
});
const run = (files, options = { threshold: THRESHOLD }) =>
new Promise((resolve, reject) => {
const stream = zopfli(options);
const out = [];
stream.on("data", (result) => out.push(result));
stream.on("error", reject);
stream.on("end", () => resolve(out));
files.forEach((entry) => stream.write(entry));
stream.end();
});
// What the old in-process plugin did, for byte-for-byte comparison.
const gzipInProcess = (contents) =>
new Promise((resolve, reject) => {
gfxZopfli.gzip(contents, {}, (error, result) =>
error ? reject(error) : resolve(Buffer.from(result))
);
});
const filler = (length) => Buffer.alloc(length, "a");
describe("zopfli worker pool", () => {
it("appends .gz and matches in-process zopfli byte for byte", async () => {
const contents = filler(4096);
const [result] = await run([file("/out/app.js", contents)]);
expect(result.path).toBe("/out/app.js.gz");
expect(result.contents).toEqual(await gzipInProcess(contents));
});
it("returns contents as a Buffer, which vinyl requires", async () => {
const [result] = await run([file("/out/app.js", filler(1024))]);
expect(Buffer.isBuffer(result.contents)).toBe(true);
});
it("appends to the path rather than replacing the extension", async () => {
const [result] = await run([file("/out/nested/chunk.min.js", filler(500))]);
expect(result.path).toBe("/out/nested/chunk.min.js.gz");
});
it("passes files under the threshold through untouched", async () => {
const contents = filler(THRESHOLD - 1);
const [result] = await run([file("/out/tiny.js", contents)]);
expect(result.path).toBe("/out/tiny.js");
expect(result.contents).toBe(contents);
});
it("compresses a file exactly at the threshold", async () => {
const [result] = await run([file("/out/edge.js", filler(THRESHOLD))]);
expect(result.path).toBe("/out/edge.js.gz");
});
it("compresses everything when no threshold is given", async () => {
const [result] = await run([file("/out/tiny.js", filler(1))], {});
expect(result.path).toBe("/out/tiny.js.gz");
});
it("passes null files through", async () => {
const [result] = await run([file("/out/adirectory", null)]);
expect(result.path).toBe("/out/adirectory");
expect(result.contents).toBeNull();
});
it("compresses only the requested slice of a pooled buffer", async () => {
// Small reads share a pooled ArrayBuffer, so contents often start at a
// non-zero byteOffset into a much larger allocation.
const pool = Buffer.alloc(8192, "z");
const contents = pool.subarray(1000, 1000 + 512);
contents.fill("a");
const [result] = await run([file("/out/pooled.js", contents)]);
expect(result.contents).toEqual(await gzipInProcess(filler(512)));
});
it("buffers stream-mode contents", async () => {
const contents = filler(600);
const [result] = await run([
file("/out/streamed.js", Readable.from([contents])),
]);
expect(result.path).toBe("/out/streamed.js.gz");
expect(result.contents).toEqual(await gzipInProcess(contents));
});
it("handles more files than the pool has workers", async () => {
const count = 25;
const files = Array.from({ length: count }, (_, index) =>
file(`/out/chunk${index}.js`, filler(200 + index))
);
const results = await run(files);
expect(results).toHaveLength(count);
expect(new Set(results.map((result) => result.path)).size).toBe(count);
await Promise.all(
results.map(async (result) => {
const index = Number(result.path.match(/chunk(\d+)/)[1]);
expect(result.contents).toEqual(
await gzipInProcess(filler(200 + index))
);
})
);
});
it("serves two concurrent streams from the same pool", async () => {
const [first, second] = await Promise.all([
run([file("/out/a.js", filler(300))]),
run([file("/out/b.js", filler(400))]),
]);
expect(first[0].path).toBe("/out/a.js.gz");
expect(second[0].path).toBe("/out/b.js.gz");
expect(first[0].contents).toEqual(await gzipInProcess(filler(300)));
expect(second[0].contents).toEqual(await gzipInProcess(filler(400)));
});
});
+40
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
formatCallyMonthYear,
formatDate,
formatDateWeekdayDay,
formatDateShort,
@@ -263,4 +264,43 @@ describe("formatDate", () => {
).toBe("Sat");
});
});
describe("formatCallyMonthYear", () => {
// How cally hands out a calendar day: a Date anchored to UTC midnight
const julyFirst = new Date(Date.UTC(2026, 6, 1));
const januaryFirst = new Date(Date.UTC(2026, 0, 1));
// demoConfig.time_zone is America/Los_Angeles, so this resolves to a
// negative UTC offset whatever zone the tests run in
const locale = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
it("Formats in UTC rather than the resolved time zone", () => {
// A time zone aware formatter reads UTC midnight on the 1st as the
// previous month, which desyncs the header from the calendar grid
expect(formatDateMonthYear(julyFirst, locale, demoConfig)).toBe(
"June 2026"
);
expect(formatCallyMonthYear(julyFirst, locale)).toBe("July 2026");
});
it("Keeps the year on a January page", () => {
expect(formatDateMonthYear(januaryFirst, locale, demoConfig)).toBe(
"December 2025"
);
expect(formatCallyMonthYear(januaryFirst, locale)).toBe("January 2026");
});
it("Orders month and year by locale", () => {
expect(
formatCallyMonthYear(julyFirst, { ...locale, language: "ja" })
).toBe("2026年7月");
});
});
});
+88
View File
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { canGoBack, goBack, navigate } from "../../src/common/navigate";
// navigate() closes open dialogs before touching history.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
closeAllDialogs: vi.fn(async () => true),
}));
// Sets up a raw entry the way a document load does, which is exactly what the
// helpers in common/navigate refuse to produce.
const setEntry = (path: string, state: unknown = null) => {
window.history.replaceState(state, "", path);
};
describe("navigate", () => {
beforeEach(() => {
setEntry("/config");
});
it("stamps the path we came from on pushed entries", async () => {
await navigate("/config/devices/dashboard");
expect(window.location.pathname).toEqual("/config/devices/dashboard");
expect(window.history.state).toMatchObject({ from: "/config" });
});
it("keeps caller data alongside the stamp", async () => {
await navigate("/config/areas", { data: { scrollPosition: 42 } });
expect(window.history.state).toMatchObject({
scrollPosition: 42,
from: "/config",
});
});
it("keeps the stamp when replacing, the predecessor is unchanged", async () => {
await navigate("/config/cloud");
await navigate("/config/cloud/account", { replace: true });
expect(window.location.pathname).toEqual("/config/cloud/account");
expect(window.history.state).toMatchObject({ from: "/config" });
});
it("does not stamp an entry the app did not push", () => {
expect(window.history.state).toBeNull();
expect(canGoBack()).toBe(false);
});
});
describe("goBack", () => {
beforeEach(() => {
setEntry("/config/cloud/remote");
vi.restoreAllMocks();
});
it("goes back when we came from another page in the app", async () => {
const back = vi
.spyOn(window.history, "back")
.mockImplementation(() => undefined);
await navigate("/config/cloud/remote");
await goBack("/config/cloud/account");
expect(back).toHaveBeenCalledOnce();
});
it("falls back to the given path when the previous entry is not ours", async () => {
// A login redirect lands on the page through location.assign, leaving
// /auth/authorize behind: going back there would leave the app.
const back = vi
.spyOn(window.history, "back")
.mockImplementation(() => undefined);
await goBack("/config/cloud/account");
expect(back).not.toHaveBeenCalled();
expect(window.location.pathname).toEqual("/config/cloud/account");
});
it("falls back to the root when no path is given", async () => {
vi.spyOn(window.history, "back").mockImplementation(() => undefined);
await goBack();
expect(window.location.pathname).toEqual("/");
});
});
@@ -1,123 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "../../src/components/ha-control-number-buttons";
import type { HaControlNumberButton } from "../../src/components/ha-control-number-buttons";
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
describe("ha-control-number-buttons step bounds", () => {
// An input_number with min 1, max 99 and step 10: the step grid does not
// divide the range, so snapping used to round past both bounds.
const RANGE = { min: 1, max: 99, step: 10 };
let buttons: HaControlNumberButton[] = [];
const mountButtons = async (
props: Partial<HaControlNumberButton>
): Promise<HaControlNumberButton> => {
const el = document.createElement(
"ha-control-number-buttons"
) as HaControlNumberButton;
Object.assign(el, props);
document.body.appendChild(el);
buttons.push(el);
await el.updateComplete;
return el;
};
const stepButton = (el: HaControlNumberButton, label: string) =>
el.shadowRoot!.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!;
const changedValues = (el: HaControlNumberButton) => {
const values: (number | undefined)[] = [];
el.addEventListener("value-changed", (ev) => {
values.push((ev as CustomEvent).detail.value);
});
return values;
};
const pressKey = async (el: HaControlNumberButton, code: string) => {
el.shadowRoot!.querySelector('[role="spinbutton"]')!.dispatchEvent(
new KeyboardEvent("keydown", {
code,
bubbles: true,
composed: true,
cancelable: true,
})
);
await el.updateComplete;
};
beforeEach(() => {
vi.stubGlobal("ResizeObserver", MockResizeObserver);
});
afterEach(() => {
buttons.forEach((el) => el.remove());
buttons = [];
vi.unstubAllGlobals();
});
it("stops at the maximum instead of stepping past it", async () => {
const el = await mountButtons({ ...RANGE, value: 91 });
const values = changedValues(el);
stepButton(el, "increment").click();
await el.updateComplete;
expect(values).toEqual([99]);
expect(stepButton(el, "increment").disabled).toBe(true);
});
it("stops at the minimum instead of stepping past it", async () => {
const el = await mountButtons({ ...RANGE, value: 11 });
const values = changedValues(el);
stepButton(el, "decrement").click();
await el.updateComplete;
expect(values).toEqual([1]);
expect(stepButton(el, "decrement").disabled).toBe(true);
});
it("still snaps to the step grid away from the bounds", async () => {
const el = await mountButtons({ ...RANGE, value: 44 });
const values = changedValues(el);
stepButton(el, "increment").click();
stepButton(el, "decrement").click();
await el.updateComplete;
expect(values).toEqual([50, 40]);
});
it("steps without bounds when min and max are not set", async () => {
const el = await mountButtons({ step: 10, value: 50 });
const values = changedValues(el);
stepButton(el, "increment").click();
await el.updateComplete;
expect(values).toEqual([60]);
});
it("keeps arrow keys inside the bounds", async () => {
const el = await mountButtons({ ...RANGE, value: 91 });
const values = changedValues(el);
await pressKey(el, "ArrowUp");
expect(values).toEqual([99]);
el.value = 11;
await pressKey(el, "ArrowDown");
expect(values).toEqual([99, 1]);
});
it("pages by ten percent of the range, but at least one step", async () => {
const wide = await mountButtons({ min: 10, max: 20, step: 1, value: 15 });
const wideValues = changedValues(wide);
await pressKey(wide, "PageUp");
expect(wideValues).toEqual([16]);
// A range narrower than ten steps must still page by a full step.
const narrow = await mountButtons({ min: 0, max: 5, step: 2, value: 0 });
const narrowValues = changedValues(narrow);
await pressKey(narrow, "PageUp");
expect(narrowValues).toEqual([2]);
});
});
+17 -97
View File
@@ -8,23 +8,6 @@ const createSlider = (props: Partial<HaControlSlider>): HaControlSlider => {
return el;
};
let sliders: HaControlSlider[] = [];
const mountSlider = async (
props: Partial<HaControlSlider>
): Promise<HaControlSlider> => {
const el = createSlider(props);
document.body.appendChild(el);
sliders.push(el);
await el.updateComplete;
return el;
};
afterEach(() => {
sliders.forEach((el) => el.remove());
sliders = [];
});
describe("ha-control-slider value mapping", () => {
afterEach(() => {
document.dir = "ltr";
@@ -71,6 +54,18 @@ describe("ha-control-slider display rounding", () => {
// stepped percentage such as 29 snaps to 26 * step = 28.5714…
const FAN_STEP = 100 / 91;
let sliders: HaControlSlider[] = [];
const mountSlider = async (
props: Partial<HaControlSlider>
): Promise<HaControlSlider> => {
const el = createSlider(props);
document.body.appendChild(el);
sliders.push(el);
await el.updateComplete;
return el;
};
const ariaValueNow = (el: HaControlSlider) =>
el
.shadowRoot!.querySelector('[role="slider"]')!
@@ -79,6 +74,11 @@ describe("ha-control-slider display rounding", () => {
const tooltipText = (el: HaControlSlider) =>
el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim();
afterEach(() => {
sliders.forEach((el) => el.remove());
sliders = [];
});
it("shows the fractional stepped value by default", async () => {
const el = await mountSlider({ step: FAN_STEP, value: 29 });
expect(tooltipText(el)).toBe("28.57");
@@ -113,83 +113,3 @@ describe("ha-control-slider display rounding", () => {
expect(ariaValueNow(el)).toBe("21.5");
});
});
describe("ha-control-slider step bounds", () => {
// An input_number with min 1, max 99 and step 10: the step grid does not
// divide the range, so snapping used to round past both bounds.
const RANGE = { min: 1, max: 99, step: 10 };
const pressKey = async (el: HaControlSlider, code: string) => {
const slider = el.shadowRoot!.querySelector('[role="slider"]')!;
const init = { code, bubbles: true, composed: true, cancelable: true };
slider.dispatchEvent(new KeyboardEvent("keydown", init));
slider.dispatchEvent(new KeyboardEvent("keyup", init));
await el.updateComplete;
};
const changedValues = (el: HaControlSlider) => {
const values: (number | undefined)[] = [];
el.addEventListener("value-changed", (ev) => {
values.push((ev as CustomEvent).detail.value);
});
return values;
};
it("snaps within the bounds instead of rounding past them", () => {
const el = createSlider(RANGE);
expect(el.steppedValue(99)).toBe(99);
expect(el.steppedValue(1)).toBe(1);
// Values away from the bounds still snap to the step grid.
expect(el.steppedValue(44)).toBe(40);
expect(el.steppedValue(46)).toBe(50);
});
it("stays inside the bounds along the whole pointer path", () => {
// The pointer handlers compose these two, so they have to hold together.
const el = createSlider(RANGE);
expect(el.steppedValue(el.percentageToValue(1))).toBe(99);
expect(el.steppedValue(el.percentageToValue(0))).toBe(1);
});
it("does not overshoot the maximum with a fractional step", () => {
// A 91-speed fan: 91 * (100 / 91) used to land on 100.00000000000001.
const el = createSlider({ min: 0, max: 100, step: 100 / 91 });
expect(el.steppedValue(el.percentageToValue(1))).toBe(100);
});
it("shows and announces the bound, not the overshoot", async () => {
const el = await mountSlider({
...RANGE,
value: 99,
tooltipMode: "always",
});
expect(el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim()).toBe(
"99"
);
expect(
el
.shadowRoot!.querySelector('[role="slider"]')!
.getAttribute("aria-valuenow")
).toBe("99");
});
it("keeps paging inside the bounds", async () => {
const el = await mountSlider({ ...RANGE, value: 91 });
const values = changedValues(el);
await pressKey(el, "PageUp");
expect(values).toEqual([99]);
el.value = 1;
await pressKey(el, "PageDown");
expect(values).toEqual([99, 1]);
});
it("keeps arrow keys inside the bounds", async () => {
const el = await mountSlider({ ...RANGE, value: 91 });
const values = changedValues(el);
await pressKey(el, "ArrowUp");
expect(values).toEqual([99]);
el.value = 1;
await pressKey(el, "ArrowDown");
expect(values).toEqual([99, 1]);
});
});
+2 -2
View File
@@ -28,7 +28,7 @@ afterEach(() => {
describe("hass-subpage back path", () => {
it("links to a path on the current origin", async () => {
const backButton = await mount("/config/system");
expect(backButton!.getAttribute("href")).toEqual("/config/system");
expect(backButton!.href).toEqual("/config/system");
});
// eslint-disable-next-line no-script-url
@@ -36,7 +36,7 @@ describe("hass-subpage back path", () => {
"does not link to %s",
async (backPath) => {
const backButton = await mount(backPath);
expect(backButton!.hasAttribute("href")).toBe(false);
expect(backButton!.href).toBeUndefined();
}
);
});
+4 -51
View File
@@ -2945,7 +2945,7 @@ __metadata:
languageName: node
linkType: hard
"@gfx/zopfli@npm:^1.0.15":
"@gfx/zopfli@npm:1.0.15":
version: 1.0.15
resolution: "@gfx/zopfli@npm:1.0.15"
dependencies:
@@ -6744,13 +6744,6 @@ __metadata:
languageName: node
linkType: hard
"any-promise@npm:^1.1.0":
version: 1.3.0
resolution: "any-promise@npm:1.3.0"
checksum: 10/6737469ba353b5becf29e4dc3680736b9caa06d300bda6548812a8fee63ae7d336d756f88572fa6b5219aed36698d808fa55f62af3e7e6845c7a1dc77d240edb
languageName: node
linkType: hard
"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2":
version: 3.1.3
resolution: "anymatch@npm:3.1.3"
@@ -7328,7 +7321,7 @@ __metadata:
languageName: node
linkType: hard
"bytes@npm:3.1.2, bytes@npm:^3.1.2":
"bytes@npm:3.1.2":
version: 3.1.2
resolution: "bytes@npm:3.1.2"
checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388
@@ -9773,19 +9766,6 @@ __metadata:
languageName: node
linkType: hard
"gulp-zopfli-green@npm:7.0.0":
version: 7.0.0
resolution: "gulp-zopfli-green@npm:7.0.0"
dependencies:
"@gfx/zopfli": "npm:^1.0.15"
bytes: "npm:^3.1.2"
plugin-error: "npm:^2.0.1"
stream-to-array: "npm:^2.3.0"
through2: "npm:^4.0.2"
checksum: 10/25db42b9a8f6395847e3cc06c548c3f3d51c84606166f18de71d91d505e7293176b45b4b78b7e0b1566c26263fe0dc212e56d2b367461c3d712bee9d22d5224f
languageName: node
linkType: hard
"gulp@npm:5.0.1":
version: 5.0.1
resolution: "gulp@npm:5.0.1"
@@ -9919,6 +9899,7 @@ __metadata:
"@fullcalendar/list": "npm:6.1.21"
"@fullcalendar/luxon3": "npm:6.1.21"
"@fullcalendar/timegrid": "npm:6.1.21"
"@gfx/zopfli": "npm:1.0.15"
"@home-assistant/webawesome": "npm:3.7.0-ha.0"
"@html-eslint/eslint-plugin": "npm:0.64.0"
"@lezer/highlight": "npm:1.2.3"
@@ -10001,7 +9982,6 @@ __metadata:
gulp-brotli: "npm:3.0.0"
gulp-json-transform: "npm:0.5.0"
gulp-rename: "npm:2.1.0"
gulp-zopfli-green: "npm:7.0.0"
hls.js: "npm:1.6.16"
home-assistant-js-websocket: "npm:9.6.0"
html-minifier-terser: "npm:7.2.0"
@@ -12784,15 +12764,6 @@ __metadata:
languageName: node
linkType: hard
"plugin-error@npm:^2.0.1":
version: 2.0.1
resolution: "plugin-error@npm:2.0.1"
dependencies:
ansi-colors: "npm:^1.0.1"
checksum: 10/9a4f91461cd24cce401112098969991d7aa6b4c94f78e0381234280c07da779570a8b21ab143292b534ec0117c09705a67e5d756c1c303d4706fdd7f861bf5bc
languageName: node
linkType: hard
"pngjs@npm:^3.0.0":
version: 3.4.0
resolution: "pngjs@npm:3.4.0"
@@ -13006,7 +12977,7 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:2 || 3, readable-stream@npm:3, readable-stream@npm:^3.4.0":
"readable-stream@npm:2 || 3, readable-stream@npm:^3.4.0":
version: 3.6.2
resolution: "readable-stream@npm:3.6.2"
dependencies:
@@ -14194,15 +14165,6 @@ __metadata:
languageName: node
linkType: hard
"stream-to-array@npm:^2.3.0":
version: 2.3.0
resolution: "stream-to-array@npm:2.3.0"
dependencies:
any-promise: "npm:^1.1.0"
checksum: 10/7feaf63b38399b850615e6ffcaa951e96e4c8f46745dbce4b553a94c5dc43966933813747014935a3ff97793e7f30a65270bde19f82b2932871a1879229a77cf
languageName: node
linkType: hard
"streamx@npm:^2.12.0, streamx@npm:^2.12.5, streamx@npm:^2.13.2, streamx@npm:^2.14.0":
version: 2.28.0
resolution: "streamx@npm:2.28.0"
@@ -14609,15 +14571,6 @@ __metadata:
languageName: node
linkType: hard
"through2@npm:^4.0.2":
version: 4.0.2
resolution: "through2@npm:4.0.2"
dependencies:
readable-stream: "npm:3"
checksum: 10/72c246233d9a989bbebeb6b698ef0b7b9064cb1c47930f79b25d87b6c867e075432811f69b7b2ac8da00ca308191c507bdab913944be8019ac43b036ce88f6ba
languageName: node
linkType: hard
"time-stamp@npm:^1.0.0":
version: 1.1.0
resolution: "time-stamp@npm:1.1.0"