Compare commits

..
23 changed files with 761 additions and 1412 deletions
-1
View File
@@ -228,7 +228,6 @@ export default [
"entity-state",
"ha-markdown",
"integration-card",
"cloud-account",
"box-shadow",
"util-long-press",
"remove-delete-add-create",
@@ -106,17 +106,6 @@ export class DemoHaSelectBox extends LitElement {
</ha-card>
`;
})}
<ha-card>
<div class="card-content">
<label>Disabled with a selected option</label>
<ha-select-box
.value=${"card"}
.options=${fullOptions}
.disabled=${true}
>
</ha-select-box>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<p class="title"><b>Column layout</b></p>
@@ -1,8 +0,0 @@
---
title: Cloud account
---
The [Home Assistant Cloud](https://www.nabucasa.com/) account page, rendered from
mocked cloud data. The controls at the top flip the mocked subscription, remote,
backup, onboarding, and feature state so every UI state can be previewed here
without a real cloud account.
-568
View File
@@ -1,568 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-select";
import type { HaSelectSelectEvent } from "../../../../src/components/ha-select";
import "../../../../src/components/ha-switch";
import type { HaSwitch } from "../../../../src/components/ha-switch";
import type { BackupConfig } from "../../../../src/data/backup";
import { BackupScheduleRecurrence } from "../../../../src/data/backup";
import type {
CloudStatusLoggedIn,
RemoteCertificateStatus,
SubscriptionInfo,
} from "../../../../src/data/cloud";
import { ONBOARDING_ITEMS } from "../../../../src/data/cloud";
import type { Webhook } from "../../../../src/data/webhook";
import type { ShowDialogParams } from "../../../../src/dialogs/make-dialog-manager";
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
import "../../../../src/panels/config/cloud/account/cloud-account-onboarding";
import "../../../../src/panels/config/cloud/account/cloud-account-overview";
import { onboardingComplete } from "../../../../src/panels/config/cloud/account/cloud-account-status";
import { showCloudOnboardingDialog } from "../../../../src/panels/config/cloud/account/show-dialog-cloud-onboarding";
import type { HomeAssistant } from "../../../../src/types";
// This demo renders the self-contained cloud account cards (overview +
// onboarding) directly, driven by mocked data, with controls to flip the
// subscription, remote, backup, onboarding, and feature state so every UI state
// can be previewed. It intentionally does NOT render the <cloud-account> panel
// wrapper, which adds a hass-subpage toolbar/back button and route links that
// have no home in the gallery. See src/panels/config/cloud/account.
// The five PaymentSubscriptionState values.
type DemoCloudAccount =
"active" | "trialing" | "canceled" | "expired" | "unknown";
// "local": automatic backups run, but only to the local agent (no cloud copy).
// "none": no automatic backups at all.
type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
interface CloudDemoScenario {
account: DemoCloudAccount;
onboarded: boolean;
postponed: boolean;
remote: boolean;
remoteStatus: RemoteCertificateStatus;
backup: DemoCloudBackup;
alexa: boolean;
google: boolean;
webrtc: boolean;
webhooks: boolean;
}
const DEFAULT_SCENARIO: CloudDemoScenario = {
account: "active",
// Onboarding not completed and streaming (WebRTC) left off, so the onboarding
// card shows by default with streaming as the remaining step.
onboarded: false,
postponed: false,
remote: true,
remoteStatus: "ready",
backup: "fresh",
alexa: true,
google: true,
webrtc: false,
webhooks: true,
};
const SUBSCRIPTION_OPTIONS: { value: DemoCloudAccount; label: string }[] = [
{ value: "active", label: "Active" },
{ value: "trialing", label: "Trialing" },
{ value: "canceled", label: "Canceled" },
{ value: "expired", label: "Expired" },
{ value: "unknown", label: "Unknown" },
];
const REMOTE_STATUS_OPTIONS: {
value: RemoteCertificateStatus;
label: string;
}[] = [
{ value: "ready", label: "Ready" },
{ value: "generating", label: "Preparing" },
{ value: "loading", label: "Loading" },
{ value: "loaded", label: "Loaded" },
{ value: "error", label: "Error" },
];
const BACKUP_OPTIONS: { value: DemoCloudBackup; label: string }[] = [
{ value: "fresh", label: "Recent" },
{ value: "stale", label: "Old" },
{ value: "failed", label: "Failed" },
{ value: "local", label: "Local only" },
{ value: "none", label: "None" },
];
const TOGGLES: [keyof CloudDemoScenario, string][] = [
["onboarded", "Onboarded"],
["postponed", "Onboarding postponed"],
["remote", "Remote access"],
["alexa", "Alexa linked"],
["google", "Google linked"],
["webrtc", "Cameras (WebRTC)"],
["webhooks", "Has webhooks"],
];
const CLOUD_AGENT = "cloud.cloud";
const emptyFilter = () => ({
include_domains: [],
include_entities: [],
exclude_domains: [],
exclude_entities: [],
});
const demoWebhooks: Webhook[] = [
{
webhook_id: "demo_front_door",
domain: "automation",
name: "Front door motion",
local_only: false,
},
{
webhook_id: "demo_companion_app",
domain: "mobile_app",
name: "Companion app",
local_only: false,
},
];
const buildSubscription = (scenario: CloudDemoScenario): SubscriptionInfo => ({
human_description: "Demo subscription, renews automatically",
provider: "Nabu Casa, Inc.",
plan_renewal_date: 4102444800,
subscription: { status: scenario.account },
});
const buildCloudStatus = (scenario: CloudDemoScenario): CloudStatusLoggedIn => {
const active =
scenario.account !== "canceled" && scenario.account !== "expired";
const cloudhooks = scenario.webhooks
? Object.fromEntries(
demoWebhooks.map((webhook) => [
webhook.webhook_id,
{
webhook_id: webhook.webhook_id,
cloudhook_id: `demo-${webhook.webhook_id}`,
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
managed: false,
},
])
)
: {};
return {
logged_in: true,
cloud: "connected",
cloud_last_disconnect_reason: null,
email: "[email protected]",
google_registered: scenario.google,
google_entities: emptyFilter(),
google_domains: ["light", "switch", "climate", "cover"],
alexa_registered: scenario.alexa,
alexa_entities: emptyFilter(),
remote_domain: "demo-instance.ui.nabu.casa",
remote_connected: scenario.remote,
remote_certificate: {
common_name: "demo-instance.ui.nabu.casa",
expire_date: "2099-01-01T00:00:00+00:00",
fingerprint: "demodemodemodemodemodemodemodemodemodemodemodemodemo",
alternative_names: ["demo-instance.ui.nabu.casa"],
},
remote_certificate_status: scenario.remoteStatus,
http_use_ssl: false,
active_subscription: active,
onboarding_postponed: scenario.postponed,
onboarding_completed: scenario.onboarded,
prefs: {
google_enabled: scenario.google,
alexa_enabled: scenario.alexa,
remote_enabled: scenario.remote,
remote_allow_remote_enable: true,
strict_connection: "disabled",
google_secure_devices_pin: undefined,
cloudhooks,
alexa_report_state: true,
google_report_state: true,
tts_default_voice: ["en-US", "JennyNeural"],
cloud_ice_servers_enabled: scenario.webrtc,
onboarded_items: scenario.onboarded ? [...ONBOARDING_ITEMS] : [],
onboarding_postponed_until: scenario.postponed
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
: null,
},
};
};
const buildBackupConfig = (scenario: CloudDemoScenario): BackupConfig => {
const now = Date.now();
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
const old = new Date(now - 5 * 86400000).toISOString();
const future = new Date(now + 86400000).toISOString();
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
const cloudEnabled =
scenario.backup === "fresh" ||
scenario.backup === "stale" ||
scenario.backup === "failed";
let configured = true;
let lastCompleted: string | null = null;
let lastAttempted: string | null = null;
let next: string | null = null;
switch (scenario.backup) {
case "fresh":
case "local":
lastCompleted = recent;
lastAttempted = recent;
next = future;
break;
case "stale":
lastCompleted = old;
lastAttempted = old;
next = overdue;
break;
case "failed":
lastCompleted = old;
lastAttempted = recent;
next = future;
break;
case "none":
configured = false;
break;
}
return {
automatic_backups_configured: configured,
last_attempted_automatic_backup: lastAttempted,
last_completed_automatic_backup: lastCompleted,
next_automatic_backup: next,
next_automatic_backup_additional: false,
create_backup: {
agent_ids: cloudEnabled
? ["backup.local", CLOUD_AGENT]
: ["backup.local"],
include_addons: [],
include_all_addons: true,
include_database: true,
include_folders: [],
name: null,
password: null,
},
retention: { copies: 3, days: null },
schedule: {
recurrence: BackupScheduleRecurrence.DAILY,
time: null,
days: [],
},
agents: {
"backup.local": { protected: true, retention: null },
"cloud.cloud": { protected: true, retention: null },
},
};
};
@customElement("demo-misc-cloud-account")
export class DemoMiscCloudAccount
extends LitElement
implements ProvideHassElement
{
@state() private hass!: HomeAssistant;
@state() private _scenario: CloudDemoScenario = { ...DEFAULT_SCENARIO };
@state() private _cloudStatus!: CloudStatusLoggedIn;
@state() private _subscription!: SubscriptionInfo;
@state() private _backupConfig!: BackupConfig;
constructor() {
super();
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
hass.updateHass({
config: {
...hass.config,
components: [
...(hass.config?.components ?? []),
"cloud",
"backup",
"webhook",
],
},
});
this._registerMocks(hass);
this._applyScenario();
}
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._showDialog);
this.addEventListener("cloud-open-onboarding", this._openOnboarding);
this.addEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
// The overview and onboarding dialog contain real <a href="/config/..">
// links to panel routes that do not exist in the gallery. Keep them inert.
this.addEventListener("click", this._neutralizeNavigation);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._showDialog);
this.removeEventListener("cloud-open-onboarding", this._openOnboarding);
this.removeEventListener("ha-refresh-cloud-status", this._refreshFromMocks);
this.removeEventListener("click", this._neutralizeNavigation);
}
protected render() {
if (!this.hass) {
return nothing;
}
const showOnboarding =
this._cloudStatus.active_subscription &&
!this._cloudStatus.onboarding_completed &&
!this._cloudStatus.onboarding_postponed &&
!onboardingComplete(this._cloudStatus, this._backupConfig);
return html`
<div class="options">
<div class="selects">
${this._select("Subscription", "account", SUBSCRIPTION_OPTIONS)}
${this._select("Remote status", "remoteStatus", REMOTE_STATUS_OPTIONS)}
${this._select("Backups", "backup", BACKUP_OPTIONS)}
</div>
<div class="switches">
${TOGGLES.map(([field, label]) => this._toggle(label, field))}
</div>
</div>
<div class="preview">
${
showOnboarding
? html`
<cloud-account-onboarding
.hass=${this.hass}
.cloudStatus=${this._cloudStatus}
.backupConfig=${this._backupConfig}
></cloud-account-onboarding>
`
: nothing
}
<cloud-account-overview
.hass=${this.hass}
.cloudStatus=${this._cloudStatus}
.subscription=${this._subscription}
.backupConfig=${this._backupConfig}
.webhooks=${demoWebhooks}
></cloud-account-overview>
</div>
`;
}
private _select(
label: string,
field: keyof CloudDemoScenario,
options: { value: string; label: string }[]
) {
return html`
<ha-select
.label=${label}
.value=${String(this._scenario[field])}
.options=${options}
data-field=${field}
@selected=${this._selectChanged}
></ha-select>
`;
}
private _toggle(label: string, field: keyof CloudDemoScenario) {
return html`
<ha-formfield .label=${label}>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._switchChanged}
></ha-switch>
</ha-formfield>
`;
}
private _selectChanged(ev: HaSelectSelectEvent) {
const field = (ev.currentTarget as HTMLElement).dataset
.field as keyof CloudDemoScenario;
this._setField(field, ev.detail.value as string);
}
private _switchChanged(ev: Event) {
const target = ev.target as HaSwitch;
this._setField(
target.dataset.field as keyof CloudDemoScenario,
target.checked
);
}
private _setField(field: keyof CloudDemoScenario, value: string | boolean) {
if (this._scenario[field] === value) {
return;
}
this._scenario = { ...this._scenario, [field]: value };
this._applyScenario();
}
private _applyScenario() {
this._cloudStatus = buildCloudStatus(this._scenario);
this._subscription = buildSubscription(this._scenario);
this._backupConfig = buildBackupConfig(this._scenario);
}
private _openOnboarding = () => {
showCloudOnboardingDialog(this, {
cloudStatus: this._cloudStatus,
backupConfig: this._backupConfig,
onChanged: () => this._refreshFromMocks(),
});
};
private _showDialog = (ev: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
ev.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
private _refreshFromMocks = () => {
this._cloudStatus = {
...this._cloudStatus,
prefs: { ...this._cloudStatus.prefs },
};
this._backupConfig = { ...this._backupConfig };
const cloudBackup =
this._backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
this._scenario = {
...this._scenario,
onboarded: this._cloudStatus.onboarding_completed,
postponed: this._cloudStatus.onboarding_postponed,
remote: this._cloudStatus.prefs.remote_enabled,
webrtc: this._cloudStatus.prefs.cloud_ice_servers_enabled,
backup: !this._backupConfig.automatic_backups_configured
? "none"
: cloudBackup
? this._scenario.backup === "fresh" ||
this._scenario.backup === "stale" ||
this._scenario.backup === "failed"
? this._scenario.backup
: "fresh"
: "local",
};
};
private _neutralizeNavigation = (ev: MouseEvent) => {
const anchor = ev
.composedPath()
.find((el): el is HTMLAnchorElement => el instanceof HTMLAnchorElement);
if (anchor?.getAttribute("href")?.startsWith("/")) {
ev.preventDefault();
}
};
private _registerMocks(hass: MockHomeAssistant) {
hass.mockWS("cloud/status", () => ({
...this._cloudStatus,
prefs: { ...this._cloudStatus.prefs },
}));
hass.mockWS("cloud/update_prefs", (msg) => {
const { type, ...prefs } = msg;
this._cloudStatus.prefs = { ...this._cloudStatus.prefs, ...prefs };
return { success: true };
});
hass.mockWS("cloud/onboarding/postpone", () => {
this._cloudStatus.onboarding_postponed = true;
this._cloudStatus.prefs.onboarding_postponed_until = new Date(
Date.now() + 24 * 3600 * 1000
).toISOString();
return { ...this._cloudStatus, prefs: { ...this._cloudStatus.prefs } };
});
hass.mockWS("cloud/remote/connect", () => {
this._cloudStatus.remote_connected = true;
this._cloudStatus.prefs.remote_enabled = true;
return null;
});
hass.mockWS("cloud/remote/disconnect", () => {
this._cloudStatus.remote_connected = false;
this._cloudStatus.prefs.remote_enabled = false;
return null;
});
hass.mockWS("backup/config/info", () => ({
config: { ...this._backupConfig },
}));
hass.mockWS("backup/config/update", (msg) => {
const { type, ...update } = msg;
if (update.create_backup) {
this._backupConfig.create_backup = {
...this._backupConfig.create_backup,
...update.create_backup,
};
}
if (update.automatic_backups_configured !== undefined) {
this._backupConfig.automatic_backups_configured =
update.automatic_backups_configured;
}
return null;
});
}
static styles = css`
.options {
max-width: 600px;
margin: 16px auto 0;
padding: 0 16px 16px;
border-bottom: 1px solid var(--divider-color);
display: flex;
flex-direction: column;
gap: 16px;
}
.selects {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.selects ha-select {
min-width: 160px;
flex: 1;
}
.switches {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
}
.preview {
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: var(--ha-space-6, 24px);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-misc-cloud-account": DemoMiscCloudAccount;
}
}
+6 -4
View File
@@ -91,6 +91,7 @@
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.1",
"cally": "0.9.2",
"chart2music": "1.20.0",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.49.0",
@@ -101,6 +102,7 @@
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.6.16",
@@ -114,7 +116,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.9",
"marked": "18.0.7",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -152,8 +154,8 @@
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.1",
"@rspack/core": "2.1.8",
"@rspack/dev-server": "2.2.0",
"@rspack/core": "2.1.7",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
@@ -210,7 +212,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.66.0",
"typescript-eslint": "8.65.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
+11 -11
View File
@@ -23,24 +23,24 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => {
return undefined;
}
let url: URL;
try {
// anchor.href is always absolute; an empty or unparseable value throws.
url = new URL(anchor.href);
} catch {
let href = anchor.href;
if (!href || href.indexOf("mailto:") !== -1) {
return undefined;
}
// Only intercept same-origin links. A different scheme, host, or port is a
// different origin (e.g. another port like ":8123") and must trigger a full
// browser navigation instead of an in-app route change. Non-http(s) schemes
// such as mailto: resolve to a null origin and are excluded here too.
if (url.origin !== window.location.origin) {
const location = window.location;
const origin = location.origin || location.protocol + "//" + location.host;
if (!href.startsWith(origin)) {
return undefined;
}
href = href.slice(origin.length);
if (href === "#") {
return undefined;
}
if (preventDefault) {
e.preventDefault();
}
return url.pathname + url.search + url.hash;
return href;
};
+231
View File
@@ -0,0 +1,231 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { EChartsType } from "echarts/core";
import type { XAXisOption, YAXisOption } from "echarts/types/dist/shared";
import { ensureArray } from "../../common/array/ensure-array";
import { formatDateTime } from "../../common/datetime/format_date_time";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { FrontendLocaleData } from "../../data/translation";
import type {
HaECSeries,
HaECSeriesItem,
} from "../../resources/echarts/echarts";
export interface ChartSonification {
update: () => void;
dispose: () => void;
}
// Series types the Chart2Music ECharts extension can turn into data points. Our
// other series (custom timelines, sankey, network graphs) have no equivalent, and
// the extension refuses the whole chart if a single series is unsupported.
const SONIFIABLE_SERIES_TYPES = new Set(["bar", "line", "pie", "scatter"]);
// Languages shipped by chart2music. Anything else falls back to its English.
const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
// Mirrors the extension's own reading of a point: it drops anything whose y is
// not a real number, so a series of nothing but nulls converts to an empty group
// and makes Chart2Music throw. Short-circuits on the first usable point.
const hasNumericPoint = (data: unknown): boolean =>
Array.isArray(data) &&
data.some((raw) => {
let y: unknown = raw;
if (Array.isArray(raw)) {
y = raw.length > 1 ? raw[1] : raw[0];
} else if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
y = Array.isArray(value)
? value.length > 1
? value[1]
: value[0]
: value;
}
return typeof y === "number" && !Number.isNaN(y);
});
export const canSonifyChart = (data: HaECSeries): boolean => {
const series = ensureArray(data);
return (
// Cards commonly push empty placeholder series, so only require that
// something is plottable — but every type has to be convertible.
series.some((s) => hasNumericPoint(s.data)) &&
series.every((s) => SONIFIABLE_SERIES_TYPES.has(s.type as string))
);
};
interface SonifyChartOptions {
cc: HTMLElement;
localize: LocalizeFunc;
locale: FrontendLocaleData;
config: HassConfig;
onError: (error: string) => void;
}
// Chart2Music appends its help and options dialogs straight to document.body, so
// they can only be themed from a document-level stylesheet.
let stylesAppended = false;
const appendSonificationStyles = () => {
if (stylesAppended) {
return;
}
stylesAppended = true;
const style = document.createElement("style");
style.textContent = `
dialog.chart2music-dialog {
box-sizing: border-box;
max-width: min(600px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
padding: var(--ha-space-6);
border: none;
border-radius: var(--ha-border-radius-lg);
background-color: var(--card-background-color);
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
box-shadow: var(--ha-box-shadow-l);
}
dialog.chart2music-dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
dialog.chart2music-dialog h1 {
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
margin-block: 0 var(--ha-space-4);
padding-inline-end: var(--ha-space-8);
}
dialog.chart2music-dialog table {
border-collapse: collapse;
width: 100%;
}
dialog.chart2music-dialog th,
dialog.chart2music-dialog td {
text-align: start;
padding: var(--ha-space-1) var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
}
dialog.chart2music-dialog a {
color: var(--primary-color);
}
dialog.chart2music-dialog > button {
/* The extension inlines "right", which does not mirror in RTL, and inline
styles can only be beaten with !important. */
inset-inline-end: var(--ha-space-4) !important;
inset-inline-start: auto !important;
top: var(--ha-space-4);
min-width: 32px;
min-height: 32px;
cursor: pointer;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-sm);
background-color: transparent;
color: var(--primary-text-color);
font: inherit;
}
`;
document.head.append(style);
};
export const sonifyChart = async (
chart: EChartsType,
options: SonifyChartOptions
): Promise<ChartSonification | null> => {
const { connect } = await import("echarts-extension-chart2music");
const { localize, locale, config } = options;
appendSonificationStyles();
// ECharts nulls its model on dispose, and the instance can be disposed while
// the chunk is in flight.
const chartOptions = chart.getOption() as ReturnType<
EChartsType["getOption"]
> | null;
if (!chartOptions) {
return null;
}
const xAxis = ensureArray(chartOptions.xAxis)[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)[0] as YAXisOption | undefined;
// Chart2Music throws while validating a group with no points, which is what
// placeholder, legend-hidden and all-null series turn into, so only offer it
// the series carrying at least one point it can read.
const seriesIndex: number[] = [];
ensureArray(chartOptions.series).forEach((s, index) => {
if (hasNumericPoint((s as HaECSeriesItem | undefined)?.data)) {
seriesIndex.push(index);
}
});
if (!seriesIndex.length) {
return null;
}
// Chart2Music always reads out an axis label, and the extension picks the wrong
// axis to name when there is no category axis, so label both explicitly.
const isTimeAxis = xAxis?.type === "time";
const x = {
label:
xAxis?.name ||
localize(
isTimeAxis
? "ui.components.history_charts.time"
: "ui.components.history_charts.category"
),
// Time series carry raw timestamps, which would otherwise be announced as
// epoch milliseconds.
format: isTimeAxis
? (value: number) => formatDateTime(new Date(value), locale, config)
: undefined,
};
const y = {
label: yAxis?.name || localize("ui.components.history_charts.value"),
};
let connection: ReturnType<typeof connect>;
try {
connection = connect(chart, {
cc: options.cc,
seriesIndex,
title: localize("ui.components.history_charts.chart"),
lang: SONIFICATION_LANGUAGES.has(locale.language)
? locale.language
: "en",
errorCallback: options.onError,
axes: { x, y },
});
} catch (err: any) {
options.onError(err?.message ?? String(err));
return null;
}
if (!connection) {
return null;
}
// Chart2Music bails out silently on mobile user agents, returning an instance
// that never wired anything up. Turning the caption container into a live
// region is the last thing it does, so use that as the "really connected" test
// rather than leaving a focus stop that does nothing.
if (!options.cc.hasAttribute("aria-live")) {
connection.dispose();
return null;
}
const connected = connection;
// The extension re-reads the chart from ECharts' own "finished" event. Run that
// through a guard of our own so a conversion failure cannot escape into
// ECharts' event dispatch and leave the chart half-rendered.
const update = () => {
try {
connected.update();
} catch (_err) {
// Keep whatever Chart2Music last read successfully.
}
};
chart.off("finished", connected.update);
chart.on("finished", update);
return {
update,
dispose: () => {
chart.off("finished", update);
connected.dispose();
},
};
};
+118 -1
View File
@@ -22,6 +22,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
@@ -47,6 +48,8 @@ import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import type { ChartSonification } from "./chart-sonification";
import { canSonifyChart, sonifyChart } from "./chart-sonification";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
@@ -146,6 +149,17 @@ export class HaChartBase extends LitElement {
@query(".chart") private _chartContainer?: HTMLDivElement;
@query(".sonification-output")
private _sonificationOutput?: HTMLDivElement;
private _sonification?: ChartSonification;
private _sonificationLoading = false;
@state() private _sonificationUnavailable = false;
@state() private _sonificationFocusHeld = false;
private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window;
@@ -198,6 +212,7 @@ export class HaChartBase extends LitElement {
while (this._listeners.length) {
this._listeners.pop()!();
}
this._disposeSonification();
this.chart?.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
@@ -312,6 +327,17 @@ export class HaChartBase extends LitElement {
}
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
// New data, or a series shown again, may well be convertible where the
// last set was not.
this._sonificationUnavailable = false;
// The connection is built from the series that had data at the time, so
// drop it and let the next focus rebuild it against the current set.
if (
this._sonification &&
(changedProps.has("_hiddenDatasets") || !canSonifyChart(this.data))
) {
this._disposeSonification();
}
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
@@ -337,6 +363,8 @@ export class HaChartBase extends LitElement {
}
protected render() {
const sonifiable =
!this._sonificationUnavailable && canSonifyChart(this.data);
return html`
<div
class="container ${classMap({ "has-height": !!this.height })}"
@@ -348,8 +376,22 @@ export class HaChartBase extends LitElement {
height: this.height ? undefined : `${this._getDefaultHeight()}px`,
})}
>
<div class="chart"></div>
<div
class="chart"
role=${ifDefined(sonifiable ? "application" : undefined)}
tabindex=${ifDefined(
sonifiable ? "0" : this._sonificationFocusHeld ? "-1" : undefined
)}
aria-label=${ifDefined(
sonifiable
? this.hass.localize("ui.components.history_charts.chart")
: undefined
)}
@focus=${this._handleChartFocus}
@blur=${this._handleChartBlur}
></div>
</div>
<div class="sonification-output"></div>
${this._renderLegend()}
<div class="top-controls ${classMap({ small: this.smallControls })}">
<slot name="search"></slot>
@@ -521,6 +563,61 @@ export class HaChartBase extends LitElement {
</div>`;
}
// Chart2Music adds ~45 kB gzipped, so it is only fetched once someone actually
// moves keyboard focus into a chart.
private async _handleChartFocus() {
if (this._sonification || this._sonificationLoading || !this.chart) {
return;
}
this._sonificationLoading = true;
try {
const sonification = await sonifyChart(this.chart, {
cc: this._sonificationOutput!,
localize: this.hass.localize,
locale: this.hass.locale,
config: this.hass.config,
onError: () => {
// Charts the extension cannot describe stay silent rather than
// dropping an error on someone who only pressed Tab.
},
});
if (!this.isConnected || !this.chart) {
sonification?.dispose();
return;
}
if (!sonification) {
// Nothing came back, so stop offering a focus stop that leads nowhere.
// Stay programmatically focusable for as long as we hold focus though:
// dropping tabindex off the active element resets focus to the document
// and costs the user their place in the tab order.
this._sonificationFocusHeld =
this.shadowRoot?.activeElement === this._chartContainer;
this._sonificationUnavailable = true;
return;
}
this._sonification = sonification;
if (this.shadowRoot?.activeElement === this._chartContainer) {
// Chart2Music reads its summary and key hints on focus, which already
// happened while it was still being fetched.
this._chartContainer!.dispatchEvent(new FocusEvent("focus"));
}
} catch (_err) {
// Never let a failure here escape a focus handler. The tab stop stays, so
// focusing the chart again retries.
} finally {
this._sonificationLoading = false;
}
}
private _handleChartBlur() {
this._sonificationFocusHeld = false;
}
private _disposeSonification() {
this._sonification?.dispose();
this._sonification = undefined;
}
private _formatTimeLabel = (value: number | Date) =>
formatTimeLabel(
value,
@@ -533,6 +630,9 @@ export class HaChartBase extends LitElement {
if (this._loading) return;
this._loading = true;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
this._disposeSonification();
if (this.chart) {
this.chart.dispose();
}
@@ -1450,6 +1550,23 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.chart:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: var(--ha-border-radius-sm);
}
/* Chart2Music renders its announcements here. It must stay in the layout for
screen readers to pick up the live region, so hide it visually only. */
.sonification-output {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
.top-controls {
position: absolute;
top: var(--ha-space-4);
+1 -2
View File
@@ -56,7 +56,6 @@ export class HaSelectBox extends LitElement {
class="list"
style=${styleMap({ "--columns": columns })}
.value=${this.value}
?disabled=${this.disabled}
@change=${this._radioChanged}
>
${this.options.map((option) => this._renderOption(option))}
@@ -101,7 +100,7 @@ export class HaSelectBox extends LitElement {
)}
aria-labelledby=${`label-${option.value}`}
.value=${option.value}
.disabled=${option.disabled || false}
.disabled=${disabled}
></ha-radio-option>
<div class="text">
<span id=${`label-${option.value}`} class="label"
@@ -53,6 +53,7 @@ export class HaSelectorAutomationBehavior extends LitElement {
value: behavior,
label: this._localizeOption(behavior, "label"),
description: this._localizeOption(behavior, "description"),
disabled: this.disabled,
...(isTrigger && {
image: {
src: `/static/images/form/automation_behavior_trigger_${behavior}.svg`,
@@ -65,7 +66,6 @@ export class HaSelectorAutomationBehavior extends LitElement {
<ha-select-box
.options=${options}
.value=${this.value ?? ""}
.disabled=${this.disabled}
max_columns="1"
?stacked_image=${isTrigger}
@value-changed=${this._valueChanged}
@@ -104,7 +104,6 @@ export class HaSelectSelector extends LitElement {
<ha-select-box
.options=${options}
.value=${this.value as string | undefined}
.disabled=${this.disabled}
@value-changed=${this._selectChanged}
.maxColumns=${this.selector.select?.box_max_columns}
></ha-select-box>
-73
View File
@@ -1,11 +1,7 @@
import type {
HassEntity,
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../common/const";
import { computeDomain } from "../common/entity/compute_domain";
import { stateActive } from "../common/entity/state_active";
import { navigate } from "../common/navigate";
import type { HomeAssistant, ServiceCallResponse } from "../types";
@@ -70,75 +66,6 @@ export type SceneMetaData = Record<
{ entity_only?: boolean | undefined }
>;
// Hand-edited scenes.yaml is parsed as YAML 1.1 by the backend, so unquoted
// on/off arrive here as booleans. The backend applies boolean states as
// on/off; mirror that so the badge reflects what activating the scene will
// actually do. Everything else the backend rejects (numbers, null, arrays,
// objects) yields undefined.
const normalizeSceneEntityState = (sceneState: unknown): string | undefined => {
if (typeof sceneState === "boolean") {
return sceneState ? "on" : "off";
}
if (typeof sceneState === "string") {
return sceneState;
}
return undefined;
};
// Builds a state object from the scene's stored target state, so the scene
// editor can render the icon the entity will have once the scene is applied
// rather than its current live icon. The parameter is typed unknown because
// the scene config API returns raw YAML: booleans, numbers, nulls, and
// malformed shapes occur in hand-edited files beyond what the SceneEntities
// union declares. Entries that hold no usable target state (an entity left
// without a value in the YAML editor parses as null, a dict may lack a state
// key) yield undefined so the caller renders no badge instead of guessing.
//
// The result is a partial HassEntity meant for badge rendering only - it has
// no last_changed, last_updated, or context.
export const sceneEntityStateObj = (
entityId: string,
sceneEntity: unknown
): HassEntity | undefined => {
if (
typeof sceneEntity !== "object" ||
sceneEntity === null ||
Array.isArray(sceneEntity)
) {
const state = normalizeSceneEntityState(sceneEntity);
return state === undefined
? undefined
: ({ entity_id: entityId, state, attributes: {} } as HassEntity);
}
const { state: sceneState, ...attributes } = sceneEntity as Record<
string,
unknown
>;
const state = normalizeSceneEntityState(sceneState);
if (state === undefined) {
return undefined;
}
// Media-derived entity pictures are snapshotted with an access token that
// is stale by the time review mode renders, which would leave the badge
// showing a broken image instead of an icon. Stable pictures on other
// domains are kept - the same policy createHistoricState applies for the
// logbook.
if (DOMAINS_WITH_DYNAMIC_PICTURE.has(computeDomain(entityId))) {
delete attributes.entity_picture;
delete attributes.entity_picture_local;
}
const stateObj = { entity_id: entityId, state, attributes } as HassEntity;
// A live entity never carries color attributes while off, and state-badge
// applies rgb_color and brightness without checking activity; drop them for
// inactive targets so a scene that turns a light off does not render an
// active-looking colored icon.
if (!stateActive(stateObj)) {
delete attributes.rgb_color;
delete attributes.brightness;
}
return stateObj;
};
export const activateScene = (
hass: HomeAssistant,
entityId: string
+7 -28
View File
@@ -55,13 +55,6 @@ export class HassRouterPage extends ReactiveElement {
private _currentLoadProm?: Promise<void>;
// True while a route change is loading and the outgoing panel (or a loading
// screen) is still shown, waiting to be replaced. While true we don't forward
// property updates, because they are meant for the incoming panel. It stays
// false when the new panel is shown immediately (no loading screen), so that
// panel keeps receiving updates while its module finishes loading.
private _replacingPanel = false;
private _panelReady = new PanelReady();
private _cache = {};
@@ -86,9 +79,9 @@ export class HassRouterPage extends ReactiveElement {
}
if (!changedProps.has("route")) {
// Skip while the outgoing panel is still shown for a pending route
// change; the update is meant for the incoming panel, not this one.
if (this.lastChild && !this._replacingPanel) {
// Do not update if we have a currentLoadProm, because that means
// that there is still an old panel shown and we're moving to a new one.
if (this.lastChild && !this._currentLoadProm) {
this.updatePageEl(this.lastChild, changedProps);
}
return;
@@ -192,15 +185,9 @@ export class HassRouterPage extends ReactiveElement {
// It will be automatically upgraded when loading done.
if (!routerOptions.showLoading) {
const loadComplete = () => {
// Ignore a stale load that resolves after a newer navigation took over.
if (this._currentPage === newPage) {
this._currentLoadProm = undefined;
}
this._currentLoadProm = undefined;
};
this._currentLoadProm = loadProm.then(loadComplete, loadComplete);
// The new panel is shown right away, so keep forwarding updates to it
// while its module loads.
this._replacingPanel = false;
this._createPanel(routerOptions, newPage, routeOptions);
return;
}
@@ -208,9 +195,6 @@ export class HassRouterPage extends ReactiveElement {
// We are only going to show the loading screen after some time.
// That way we won't have a double fast flash on fast connections.
let created = false;
// The outgoing panel stays shown until the new one has loaded; don't
// forward updates to it in the meantime.
this._replacingPanel = true;
this._showLoadingScreenTimeout = window.setTimeout(() => {
if (created || this._currentPage !== newPage) {
@@ -226,11 +210,11 @@ export class HassRouterPage extends ReactiveElement {
this._currentLoadProm = loadProm.then(
() => {
// Ignore a stale load that resolves after a newer navigation took over.
this._currentLoadProm = undefined;
// Check if we're still trying to show the same page.
if (this._currentPage !== newPage) {
return;
}
this._currentLoadProm = undefined;
created = true;
this._createPanel(
@@ -239,14 +223,9 @@ export class HassRouterPage extends ReactiveElement {
// @ts-ignore TS forgot this is not a string.
routeOptions
);
// The new panel is now shown; resume forwarding updates to it.
this._replacingPanel = false;
},
() => {
if (this._currentPage === newPage) {
this._currentLoadProm = undefined;
this._replacingPanel = false;
}
this._currentLoadProm = undefined;
}
);
}
+13 -26
View File
@@ -102,12 +102,13 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
) {
this.checkDataBaseMigration();
}
// Wait for `hass.user` to first populate so the admin guard can run; it
// arrives asynchronously after `hass.config`. `hass.user` also gets a fresh
// reference at runtime (reconnect, profile refresh via subscribeUser), so
// only trigger on the initial population (null -> user). Reconnect re-checks
// come from connection-mixin, the launch-screen swap re-check from update().
if (changedProps.has("hass") && this.hass?.user && !oldHass?.user) {
// Wait for `hass.user` to populate so the admin guard can run; it arrives
// asynchronously after `hass.config`.
if (
changedProps.has("hass") &&
this.hass?.user &&
oldHass?.user !== this.hass.user
) {
this.checkHttpPendingConfig();
}
if (
@@ -124,12 +125,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
}
protected update(changedProps: PropertyValues<this>) {
const removingLaunchScreen =
!!this.hass?.states &&
!!this.hass.config &&
!!this.hass.services &&
this._databaseMigration === false;
if (removingLaunchScreen) {
if (
this.hass?.states &&
this.hass.config &&
this.hass.services &&
this._databaseMigration === false
) {
this.render = this.renderHass;
this.update = super.update;
// partial-panel-resolver removes the launch screen after the first panel
@@ -137,13 +138,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// screen covers the frontend until frontend/loaded is sent.
}
super.update(changedProps);
if (removingLaunchScreen) {
// Surface the HTTP pending config dialog only after super.update() has
// committed the render swap above, which clears the launch screen from
// the shadow root. Appending the dialog before that render would let it
// tear the freshly-added dialog straight back out of the DOM.
this.checkHttpPendingConfig();
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
@@ -262,13 +256,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
if (__DEMO__ || this._httpPendingDialogOpen) {
return;
}
// Only show once the main UI is rendered. During startup the root swaps
// the launch screen for the app, which clears its shadow root and would
// tear the freshly-appended dialog straight back out of the DOM (closing
// it). When called too early we skip; the swap in update() re-runs this.
if (this.render !== this.renderHass) {
return;
}
if (!this.hass?.user?.is_admin) {
return;
}
@@ -128,12 +128,8 @@ class DialogAutomationSave
`;
}
private get _isDiscardDialog(): boolean {
return this._params?.onDiscard !== undefined;
}
protected _renderDiscard() {
if (!this._isDiscardDialog) {
if (!this._params?.onDiscard) {
return nothing;
}
return html`
@@ -328,14 +324,10 @@ class DialogAutomationSave
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${
!!this._params.config.alias &&
!this._isDiscardDialog &&
!this.isDirtyState
}
.disabled=${!!this._params.config.alias && !this.isDirtyState}
>
${this.hass.localize(
this._params.config.alias && !this._isDiscardDialog
this._params.config.alias && !this._params.onDiscard
? "ui.panel.config.automation.editor.rename"
: "ui.common.save"
)}
@@ -462,7 +462,7 @@ class HaConfigHttpForm extends LitElement {
"ui.panel.config.network.http.restart_address.text"
)}
</p>
<a href=${url} target="_blank" rel="noreferrer noopener">${url}</a>
<a href=${url} rel="noreferrer noopener">${url}</a>
<p class="dialog-note">
${this.hass.localize(
"ui.panel.config.network.http.restart_address.note"
+14 -48
View File
@@ -15,7 +15,7 @@ import {
mdiPlaylistEdit,
mdiTag,
} from "@mdi/js";
import type { HassEntity, HassEvent } from "home-assistant-js-websocket";
import type { HassEvent } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -67,7 +67,6 @@ import {
getSceneEditorInitData,
saveScene,
SCENE_IGNORED_DOMAINS,
sceneEntityStateObj,
showSceneEditor,
} from "../../../data/scene";
import {
@@ -448,15 +447,13 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
const integrationName = platform
? domainToName(this.hass.localize, platform)
: undefined;
const badgeStateObj = this._badgeStateObj(
entityId,
entityStateObj
);
return html`
<ha-list-item
hasMeta
?twoline=${!!secondary}
graphic="icon"
.graphic=${
this._mode === "live" ? "icon" : undefined
}
.entityId=${entityId}
@click=${
this._mode === "live"
@@ -466,10 +463,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
.noninteractive=${this._mode === "review"}
>
${
badgeStateObj
this._mode === "live"
? html`
<state-badge
.stateObj=${badgeStateObj}
.stateObj=${entityStateObj}
slot="graphic"
></state-badge>
`
@@ -555,16 +552,14 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
this.hass.localize,
computeDomain(entityId)
);
const badgeStateObj = this._badgeStateObj(
entityId,
entityStateObj
);
return html`
<ha-list-item
class="entity"
hasMeta
?twoline=${!!secondary}
graphic="icon"
.graphic=${
this._mode === "live" ? "icon" : undefined
}
.entityId=${entityId}
@click=${
this._mode === "live"
@@ -574,13 +569,11 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
.noninteractive=${this._mode === "review"}
>
${
badgeStateObj
? html`
<state-badge
.stateObj=${badgeStateObj}
slot="graphic"
></state-badge>
`
this._mode === "live"
? html` <state-badge
.stateObj=${entityStateObj}
slot="graphic"
></state-badge>`
: nothing
}
${primary}
@@ -1195,33 +1188,6 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
return { ...stateObj.attributes, state: stateObj.state };
}
// Memoized per config so re-renders reuse the same object references and
// the state badges skip work when nothing changed.
private _sceneStateObjs = memoizeOne((config?: SceneConfig) => {
const objs: Record<string, HassEntity | undefined> = {};
for (const entityId of Object.keys(config?.entities ?? {})) {
objs[entityId] = sceneEntityStateObj(
entityId,
config!.entities[entityId]
);
}
return objs;
});
// Picks the state the row's icon should reflect: the live state in live
// mode, the scene's stored target in review mode. Undefined in review mode
// when the scene holds no usable target for the entity - the row then
// renders no badge rather than a live state that could be mistaken for a
// target.
private _badgeStateObj(
entityId: string,
entityStateObj: HassEntity
): HassEntity | undefined {
return this._mode === "live"
? entityStateObj
: this._sceneStateObjs(this._config)[entityId];
}
private _generateConfigFromLive() {
this._config = {
...this._config!,
+7 -3
View File
@@ -1129,7 +1129,11 @@
"zoom_reset": "Reset zoom",
"expand_legend": "More",
"collapse_legend": "Less",
"toggle_visibility": "Toggle visibility"
"toggle_visibility": "Toggle visibility",
"chart": "Chart",
"time": "Time",
"value": "Value",
"category": "Category"
},
"map": {
"error": "Unable to load map"
@@ -3854,7 +3858,7 @@
},
"disable_view_transition": {
"title": "Disable view transitions",
"description": "Disable animated view transitions to prevent browser crash when using browser developer tools."
"description": "Disable animated view transitions to prevent browser crash when using browser dev tools."
},
"entity_diagnostic": {
"title": "Entity diagnostic",
@@ -3899,7 +3903,7 @@
},
"actions": {
"title": "Actions",
"description": "The actions tool allows you to perform any action available in Home Assistant.",
"description": "The actions dev tool allows you to perform any action available in Home Assistant.",
"call_service": "Perform action",
"response": "Response",
"column_parameter": "Parameter",
-136
View File
@@ -1,136 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { isNavigationClick } from "../../../src/common/dom/is-navigation-click";
const createAnchor = (
href: string,
attributes: Record<string, string> = {}
): HTMLAnchorElement => {
const anchor = document.createElement("a");
anchor.href = href;
for (const [name, value] of Object.entries(attributes)) {
anchor.setAttribute(name, value);
}
return anchor;
};
const clickEvent = (
anchor: HTMLAnchorElement,
overrides: Partial<MouseEvent> = {}
): MouseEvent =>
({
defaultPrevented: false,
button: 0,
metaKey: false,
ctrlKey: false,
shiftKey: false,
composedPath: () => [anchor],
preventDefault: vi.fn(),
...overrides,
}) as unknown as MouseEvent;
describe("isNavigationClick", () => {
it("returns the path for same-origin links", () => {
expect(
isNavigationClick(clickEvent(createAnchor(`${location.origin}/config`)))
).toEqual("/config");
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/energy?historyBack=1`))
)
).toEqual("/energy?historyBack=1");
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/config/areas#section`))
)
).toEqual("/config/areas#section");
});
it("does not intercept a link on a different port", () => {
// The current origin has no port, so a ported URL shares its string prefix
// but is a different origin — it must not be treated as internal.
expect(
isNavigationClick(clickEvent(createAnchor(`${location.origin}:8123/`)))
).toBeUndefined();
});
it("does not intercept a host that merely starts with the origin", () => {
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}.example.com/`))
)
).toBeUndefined();
});
it("does not intercept other origins", () => {
expect(
isNavigationClick(clickEvent(createAnchor("https://example.com/")))
).toBeUndefined();
});
it("calls preventDefault for intercepted navigations", () => {
const event = clickEvent(createAnchor(`${location.origin}/config`));
isNavigationClick(event);
expect(event.preventDefault).toHaveBeenCalled();
});
it("does not preventDefault when disabled", () => {
const event = clickEvent(createAnchor(`${location.origin}/config`));
expect(isNavigationClick(event, false)).toEqual("/config");
expect(event.preventDefault).not.toHaveBeenCalled();
});
it("ignores clicks with a target, download, or rel=external", () => {
expect(
isNavigationClick(
clickEvent(
createAnchor(`${location.origin}/config`, { target: "_blank" })
)
)
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(createAnchor(`${location.origin}/config`, { download: "" }))
)
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(
createAnchor(`${location.origin}/config`, { rel: "external" })
)
)
).toBeUndefined();
});
it("ignores modified clicks and non-left buttons", () => {
const anchor = createAnchor(`${location.origin}/config`);
expect(
isNavigationClick(clickEvent(anchor, { button: 1 }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { metaKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { ctrlKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { shiftKey: true }))
).toBeUndefined();
expect(
isNavigationClick(clickEvent(anchor, { defaultPrevented: true }))
).toBeUndefined();
});
it("ignores mailto links and clicks without an anchor", () => {
expect(
isNavigationClick(clickEvent(createAnchor("mailto:[email protected]")))
).toBeUndefined();
expect(
isNavigationClick(
clickEvent(undefined as any, {
composedPath: () => [document.createElement("div")],
})
)
).toBeUndefined();
});
});
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { canSonifyChart } from "../../../src/components/chart/chart-sonification";
import type { HaECSeries } from "../../../src/resources/echarts/echarts";
const series = (items: { type: string; data?: unknown[] }[]) =>
items as unknown as HaECSeries;
describe("canSonifyChart", () => {
it("accepts line and bar series that carry data", () => {
expect(canSonifyChart(series([{ type: "line", data: [[0, 1]] }]))).toBe(
true
);
expect(canSonifyChart(series([{ type: "bar", data: [[0, 1]] }]))).toBe(
true
);
});
it("accepts a chart whose empty placeholder series sits beside real data", () => {
expect(
canSonifyChart(
series([
{ type: "bar", data: [] },
{ type: "bar", data: [[0, 1]] },
])
)
).toBe(true);
});
it("rejects series types the extension cannot convert", () => {
expect(canSonifyChart(series([{ type: "custom", data: [[0, 1]] }]))).toBe(
false
);
expect(
canSonifyChart(
series([
{ type: "line", data: [[0, 1]] },
{ type: "sankey", data: [[0, 1]] },
])
)
).toBe(false);
});
it("rejects charts with nothing plotted", () => {
expect(canSonifyChart(series([]))).toBe(false);
expect(canSonifyChart(series([{ type: "line", data: [] }]))).toBe(false);
expect(canSonifyChart(series([{ type: "line" }]))).toBe(false);
});
it("rejects a series whose points are all gaps", () => {
// Chart2Music drops any point without a numeric y, so a series of nothing
// but nulls converts to an empty group and makes it throw.
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[0, null],
[1, null],
],
},
])
)
).toBe(false);
});
it("accepts a series that only becomes numeric partway through", () => {
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[0, null],
[1, 21.5],
],
},
])
)
).toBe(true);
});
it("reads the y out of object-form points", () => {
expect(
canSonifyChart(series([{ type: "bar", data: [{ value: [0, 0.28] }] }]))
).toBe(true);
expect(
canSonifyChart(series([{ type: "bar", data: [{ value: [0, null] }] }]))
).toBe(false);
});
});
-116
View File
@@ -1,116 +0,0 @@
import { describe, expect, it } from "vitest";
import { sceneEntityStateObj } from "../../src/data/scene";
describe("sceneEntityStateObj", () => {
it("builds a state object from string shorthand", () => {
expect(sceneEntityStateObj("light.kitchen", "on")).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: {},
});
});
it("builds a state object from the dict form", () => {
expect(
sceneEntityStateObj("light.kitchen", {
state: "on",
brightness: 180,
rgb_color: [255, 64, 112],
})
).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: { brightness: 180, rgb_color: [255, 64, 112] },
});
});
it("strips media-derived entity pictures but keeps other attributes", () => {
const sceneEntity = {
state: "playing",
entity_picture: "/api/media_player_proxy/x?token=stale",
entity_picture_local: "/local/x.jpg",
friendly_name: "Player",
};
expect(sceneEntityStateObj("media_player.x", sceneEntity)).toEqual({
entity_id: "media_player.x",
state: "playing",
attributes: { friendly_name: "Player" },
});
// The input scene config must not be mutated.
expect(sceneEntity.entity_picture).toBe(
"/api/media_player_proxy/x?token=stale"
);
});
it("keeps a stable entity picture on other domains", () => {
expect(
sceneEntityStateObj("vacuum.robot", {
state: "cleaning",
entity_picture: "/local/robot.png",
})?.attributes.entity_picture
).toBe("/local/robot.png");
});
it("returns undefined for null and undefined values", () => {
// An entity left without a value in the YAML editor parses as null.
expect(sceneEntityStateObj("light.kitchen", null)).toBeUndefined();
expect(sceneEntityStateObj("light.kitchen", undefined)).toBeUndefined();
});
it("returns undefined for an array value", () => {
expect(sceneEntityStateObj("light.kitchen", [255, 100])).toBeUndefined();
});
it("returns undefined for a numeric value", () => {
// The backend only accepts string and boolean states, so a number never
// becomes a target the scene can apply.
expect(sceneEntityStateObj("input_number.x", 23)).toBeUndefined();
expect(
sceneEntityStateObj("input_number.x", { state: 23 })
).toBeUndefined();
});
it("returns undefined when the dict holds no usable state", () => {
expect(
sceneEntityStateObj("light.kitchen", { brightness: 100 })
).toBeUndefined();
expect(
sceneEntityStateObj("light.kitchen", { state: null })
).toBeUndefined();
expect(
sceneEntityStateObj("light.kitchen", { state: { brightness: 100 } })
).toBeUndefined();
});
it("normalizes boolean shorthand to on/off like the backend does", () => {
// YAML 1.1 parses unquoted on/off in scenes.yaml as booleans.
expect(sceneEntityStateObj("light.kitchen", true)?.state).toBe("on");
expect(sceneEntityStateObj("light.kitchen", false)?.state).toBe("off");
});
it("normalizes a boolean state in the dict form", () => {
expect(sceneEntityStateObj("light.kitchen", { state: true })).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: {},
});
});
it("drops color attributes for an inactive target", () => {
// A live entity never carries these while off, and state-badge applies
// them without checking activity.
expect(
sceneEntityStateObj("light.kitchen", {
state: "off",
brightness: 180,
rgb_color: [255, 0, 0],
friendly_name: "Kitchen",
})
).toEqual({
entity_id: "light.kitchen",
state: "off",
attributes: { friendly_name: "Kitchen" },
});
});
});
+1 -200
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ReactiveElement } from "lit";
import { property, state } from "lit/decorators";
import { state } from "lit/decorators";
import type { RouterOptions } from "../../src/layouts/hass-router-page";
import { HassRouterPage } from "../../src/layouts/hass-router-page";
import { panelIsReady } from "../../src/layouts/panel-ready";
@@ -36,99 +36,9 @@ const loadedPanelImport = new Promise<void>((resolve) => {
class LoadedPanel extends HTMLElement {}
let resolvePropPanelImport: () => void;
const propPanelImport = new Promise<void>((resolve) => {
resolvePropPanelImport = resolve;
});
class PropPanel extends HTMLElement {
public value = 0;
}
// A router that shows its panel immediately (no `showLoading`) and forwards a
// property to it on every update, mirroring how the config and profile panels
// forward `hass` to their child sections.
class PropRouter extends HassRouterPage {
@property({ attribute: false }) public value = 0;
protected routerOptions: RouterOptions = {
routes: {
slow: { tag: "test-prop-panel", load: () => propPanelImport },
},
};
protected updatePageEl(el: PropPanel) {
el.value = this.value;
}
}
let resolveSecondPanelImport: () => void;
const secondPanelImport = new Promise<void>((resolve) => {
resolveSecondPanelImport = resolve;
});
class SwapFirstPanel extends HTMLElement {
public value = 0;
}
class SwapSecondPanel extends HTMLElement {
public value = 0;
}
// A router that shows a loading screen between routes, so the outgoing panel
// stays visible while the next one loads.
class SwapRouter extends HassRouterPage {
@property({ attribute: false }) public value = 0;
protected routerOptions: RouterOptions = {
showLoading: true,
routes: {
first: { tag: "test-swap-first-panel" },
second: { tag: "test-swap-second-panel", load: () => secondPanelImport },
},
};
protected updatePageEl(el: SwapFirstPanel | SwapSecondPanel) {
el.value = this.value;
}
}
let resolveRaceAlphaImport: () => void;
const raceAlphaImport = new Promise<void>((resolve) => {
resolveRaceAlphaImport = resolve;
});
let resolveRaceBetaImport: () => void;
const raceBetaImport = new Promise<void>((resolve) => {
resolveRaceBetaImport = resolve;
});
// A router where both routes have to load their module, used to check that a
// stale load resolving late does not stop `pageRendered` from waiting for the
// current route.
class RaceRouter extends HassRouterPage {
protected routerOptions: RouterOptions = {
showLoading: true,
routes: {
alpha: { tag: "test-race-alpha-panel", load: () => raceAlphaImport },
beta: { tag: "test-race-beta-panel", load: () => raceBetaImport },
},
};
public get rendered() {
return this.pageRendered;
}
}
customElements.define("test-router", TestRouter);
customElements.define("test-immediate-panel", ImmediatePanel);
customElements.define("test-deferred-panel", DeferredPanel);
customElements.define("test-prop-router", PropRouter);
customElements.define("test-prop-panel", PropPanel);
customElements.define("test-swap-router", SwapRouter);
customElements.define("test-swap-first-panel", SwapFirstPanel);
customElements.define("test-swap-second-panel", SwapSecondPanel);
customElements.define("test-race-router", RaceRouter);
let router: TestRouter | undefined;
@@ -191,120 +101,11 @@ describe("HassRouterPage panel readiness", () => {
});
});
describe("HassRouterPage update propagation during load", () => {
it("forwards updates to a panel shown while its module is still loading", async () => {
const element = document.createElement("test-prop-router") as PropRouter;
element.route = { prefix: "", path: "/slow" };
document.body.append(element);
await element.updateComplete;
const panel = element.lastElementChild as PropPanel;
expect(panel).toBeInstanceOf(PropPanel);
expect(panel.value).toBe(0);
// A new value (e.g. an updated `hass` carrying freshly loaded fragment
// translations) arrives while the panel module is still loading. The panel
// is already shown, so the update must reach it right away rather than
// being skipped as if an outgoing panel were still being replaced.
element.value = 1;
await element.updateComplete;
expect(panel.value).toBe(1);
// Finishing the load must not lose the value it received in the meantime.
resolvePropPanelImport();
await propPanelImport;
await element.updateComplete;
expect(panel.value).toBe(1);
element.remove();
});
it("does not forward updates to the outgoing panel while a new one loads", async () => {
const element = document.createElement("test-swap-router") as SwapRouter;
element.route = { prefix: "", path: "/first" };
document.body.append(element);
// A router with a loading screen creates the panel once its load resolves.
await vi.waitFor(() => {
expect(element.lastElementChild).toBeInstanceOf(SwapFirstPanel);
});
const first = element.lastElementChild as SwapFirstPanel;
expect(first.value).toBe(0);
// Navigate to a page whose module is still loading. The first panel stays
// shown in the meantime (the loading screen only appears after a delay).
element.route = { prefix: "", path: "/second" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
// An update now is meant for the incoming panel, so it must not leak to the
// outgoing one that is about to be replaced.
element.value = 1;
await element.updateComplete;
expect(first.value).toBe(0);
// Once the new panel is shown, updates resume normally.
resolveSecondPanelImport();
await vi.waitFor(() => {
expect(element.lastElementChild).toBeInstanceOf(SwapSecondPanel);
});
const second = element.lastElementChild as SwapSecondPanel;
element.value = 2;
await element.updateComplete;
expect(second.value).toBe(2);
element.remove();
});
it("keeps waiting for the current panel's load when a stale load resolves", async () => {
const element = document.createElement("test-race-router") as RaceRouter;
element.route = { prefix: "", path: "/alpha" };
document.body.append(element);
await element.updateComplete;
// Navigate to beta while alpha's module is still loading.
element.route = { prefix: "", path: "/beta" };
await element.updateComplete;
// The stale alpha load resolves late. It must not clear the tracking for
// the current (beta) load, or `pageRendered` would resolve too early.
resolveRaceAlphaImport();
customElements.define(
"test-race-alpha-panel",
class extends HTMLElement {}
);
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
let betaReady = false;
element.rendered.then(() => {
betaReady = true;
});
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
expect(betaReady).toBe(false);
// Beta finishing loading resolves it.
resolveRaceBetaImport();
customElements.define("test-race-beta-panel", class extends HTMLElement {});
await expect(element.rendered).resolves.toBeUndefined();
element.remove();
});
});
declare global {
interface HTMLElementTagNameMap {
"test-router": TestRouter;
"test-immediate-panel": ImmediatePanel;
"test-deferred-panel": DeferredPanel;
"test-loaded-panel": LoadedPanel;
"test-prop-router": PropRouter;
"test-prop-panel": PropPanel;
"test-swap-router": SwapRouter;
"test-swap-first-panel": SwapFirstPanel;
"test-swap-second-panel": SwapSecondPanel;
"test-race-router": RaceRouter;
}
}
+256 -162
View File
@@ -2561,13 +2561,13 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/core@npm:1.11.3":
version: 1.11.3
resolution: "@emnapi/core@npm:1.11.3"
"@emnapi/core@npm:1.11.2":
version: 1.11.2
resolution: "@emnapi/core@npm:1.11.2"
dependencies:
"@emnapi/wasi-threads": "npm:1.2.3"
"@emnapi/wasi-threads": "npm:1.2.2"
tslib: "npm:^2.4.0"
checksum: 10/6ed871d5bbd0f6e25a09fe91583e886e4b90ae9344fa9292c78f0891b0b2a7d84899ea65a7d576450b9ac50440c698dad1907907e3d686ec9460ed876a9363a2
checksum: 10/b3e9693f79ca1d8bb90cb8a950150c7738f54eca0ae1849d3d5103a87ce4e388c2669fb253cc123d3449ad2ae12583435455dbdc0270b1e9efb0cfd502c952b4
languageName: node
linkType: hard
@@ -2589,12 +2589,12 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/runtime@npm:1.11.3":
version: 1.11.3
resolution: "@emnapi/runtime@npm:1.11.3"
"@emnapi/runtime@npm:1.11.2":
version: 1.11.2
resolution: "@emnapi/runtime@npm:1.11.2"
dependencies:
tslib: "npm:^2.4.0"
checksum: 10/ec834cc0fe248e06dd84f6dee10162133f8a692636189e99aa992303c791d4be1679536ee91aeee6661c4478609768cee9c085acd858caa92784c67acaab44a5
checksum: 10/280a219d3bf302615dabaaa248223b0e5fe9c49bacf0987dd958ca37ab425b62cf05287053cb188e711681418aaa5e447eaed6b62a0848c0a1303b2707864600
languageName: node
linkType: hard
@@ -2616,15 +2616,6 @@ __metadata:
languageName: node
linkType: hard
"@emnapi/wasi-threads@npm:1.2.3":
version: 1.2.3
resolution: "@emnapi/wasi-threads@npm:1.2.3"
dependencies:
tslib: "npm:^2.4.0"
checksum: 10/d9fef5c321a6df1988e813e0b621ed1e01d274c23d3028ff9511af97f8935adc921ae60a35167ccf4e0414fecc040b66b9b13458e7c00cfaad622457e1b03529
languageName: node
linkType: hard
"@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1":
version: 4.9.1
resolution: "@eslint-community/eslint-utils@npm:4.9.1"
@@ -2765,6 +2756,27 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/ecma402-abstract@npm:2.3.4":
version: 2.3.4
resolution: "@formatjs/ecma402-abstract@npm:2.3.4"
dependencies:
"@formatjs/fast-memoize": "npm:2.2.7"
"@formatjs/intl-localematcher": "npm:0.6.1"
decimal.js: "npm:^10.4.3"
tslib: "npm:^2.8.0"
checksum: 10/573971ffc291096a4b9fcc80b4708124e89bf2e3ac50e0f78b41eb797e9aa1b842f4dc3665e4467a853c738386821769d9e40408a1d25bc73323a1f057a16cf2
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:2.2.7":
version: 2.2.7
resolution: "@formatjs/fast-memoize@npm:2.2.7"
dependencies:
tslib: "npm:^2.8.0"
checksum: 10/e7e6efc677d63a13d99a854305db471b69f64cbfebdcb6dbe507dab9aa7eaae482ca5de86f343c856ca0a2c8f251672bd1f37c572ce14af602c0287378097d43
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:3.1.7":
version: 3.1.7
resolution: "@formatjs/fast-memoize@npm:3.1.7"
@@ -2772,6 +2784,17 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:2.11.2":
version: 2.11.2
resolution: "@formatjs/icu-messageformat-parser@npm:2.11.2"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
"@formatjs/icu-skeleton-parser": "npm:1.8.14"
tslib: "npm:^2.8.0"
checksum: 10/e919eb2a132ac1d54fb1a7e3a3254007649b55196d3818090df92a4268dcddf20cbdf863c06039fbbe7a35a8a3f17bdc172dade99d1f17c1d8a95dcec444c3e3
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:3.5.16":
version: 3.5.16
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.16"
@@ -2781,6 +2804,16 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/icu-skeleton-parser@npm:1.8.14":
version: 1.8.14
resolution: "@formatjs/icu-skeleton-parser@npm:1.8.14"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
tslib: "npm:^2.8.0"
checksum: 10/2fbe3155c310358820b118d8c9844f314eff3500a82f1c65402434a3095823e1afeaab8d1762b4a59cc5679d82dc4c8c134683565d7cdae4daace23251f46a47
languageName: node
linkType: hard
"@formatjs/icu-skeleton-parser@npm:2.1.11":
version: 2.1.11
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.11"
@@ -2843,6 +2876,15 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.6.1":
version: 0.6.1
resolution: "@formatjs/intl-localematcher@npm:0.6.1"
dependencies:
tslib: "npm:^2.8.0"
checksum: 10/c7b3bc8395d18670677f207b2fd107561fff5d6394a9b4273c29e0bea920300ec3a2eefead600ebb7761c04a770cada28f78ac059f84d00520bfb57a9db36998
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.8.13":
version: 0.8.13
resolution: "@formatjs/intl-localematcher@npm:0.8.13"
@@ -2890,6 +2932,24 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl@npm:3.1.6":
version: 3.1.6
resolution: "@formatjs/intl@npm:3.1.6"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
"@formatjs/fast-memoize": "npm:2.2.7"
"@formatjs/icu-messageformat-parser": "npm:2.11.2"
intl-messageformat: "npm:10.7.16"
tslib: "npm:^2.8.0"
peerDependencies:
typescript: ^5.6.0
peerDependenciesMeta:
typescript:
optional: true
checksum: 10/10ebdce088898ad7de59c10890f7c02fa9f5aa50518ed3fae7e0ae1c392f3973da00464d2a42229cce97916c2eff1e41630e95c18bab01713d2b6ef5c7fd80c1
languageName: node
linkType: hard
"@fullcalendar/core@npm:6.1.21":
version: 6.1.21
resolution: "@fullcalendar/core@npm:6.1.21"
@@ -4765,110 +4825,110 @@ __metadata:
languageName: node
linkType: hard
"@rspack/binding-darwin-arm64@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-darwin-arm64@npm:2.1.8"
"@rspack/binding-darwin-arm64@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-darwin-arm64@npm:2.1.7"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-darwin-x64@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-darwin-x64@npm:2.1.8"
"@rspack/binding-darwin-x64@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-darwin-x64@npm:2.1.7"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-gnu@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.8"
"@rspack/binding-linux-arm64-gnu@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.7"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-musl@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.8"
"@rspack/binding-linux-arm64-musl@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.7"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-riscv64-gnu@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.8"
"@rspack/binding-linux-riscv64-gnu@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.7"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-riscv64-musl@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.8"
"@rspack/binding-linux-riscv64-musl@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.7"
conditions: os=linux & cpu=riscv64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-x64-gnu@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.8"
"@rspack/binding-linux-x64-gnu@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.7"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-x64-musl@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.8"
"@rspack/binding-linux-x64-musl@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.7"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-wasm32-wasi@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.8"
"@rspack/binding-wasm32-wasi@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.7"
dependencies:
"@emnapi/core": "npm:1.11.3"
"@emnapi/runtime": "npm:1.11.3"
"@emnapi/core": "npm:1.11.2"
"@emnapi/runtime": "npm:1.11.2"
"@napi-rs/wasm-runtime": "npm:1.1.6"
conditions: cpu=wasm32
languageName: node
linkType: hard
"@rspack/binding-win32-arm64-msvc@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.8"
"@rspack/binding-win32-arm64-msvc@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.7"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-win32-ia32-msvc@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.8"
"@rspack/binding-win32-ia32-msvc@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.7"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rspack/binding-win32-x64-msvc@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.8"
"@rspack/binding-win32-x64-msvc@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.7"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@rspack/binding@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/binding@npm:2.1.8"
"@rspack/binding@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/binding@npm:2.1.7"
dependencies:
"@rspack/binding-darwin-arm64": "npm:2.1.8"
"@rspack/binding-darwin-x64": "npm:2.1.8"
"@rspack/binding-linux-arm64-gnu": "npm:2.1.8"
"@rspack/binding-linux-arm64-musl": "npm:2.1.8"
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.8"
"@rspack/binding-linux-riscv64-musl": "npm:2.1.8"
"@rspack/binding-linux-x64-gnu": "npm:2.1.8"
"@rspack/binding-linux-x64-musl": "npm:2.1.8"
"@rspack/binding-wasm32-wasi": "npm:2.1.8"
"@rspack/binding-win32-arm64-msvc": "npm:2.1.8"
"@rspack/binding-win32-ia32-msvc": "npm:2.1.8"
"@rspack/binding-win32-x64-msvc": "npm:2.1.8"
"@rspack/binding-darwin-arm64": "npm:2.1.7"
"@rspack/binding-darwin-x64": "npm:2.1.7"
"@rspack/binding-linux-arm64-gnu": "npm:2.1.7"
"@rspack/binding-linux-arm64-musl": "npm:2.1.7"
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.7"
"@rspack/binding-linux-riscv64-musl": "npm:2.1.7"
"@rspack/binding-linux-x64-gnu": "npm:2.1.7"
"@rspack/binding-linux-x64-musl": "npm:2.1.7"
"@rspack/binding-wasm32-wasi": "npm:2.1.7"
"@rspack/binding-win32-arm64-msvc": "npm:2.1.7"
"@rspack/binding-win32-ia32-msvc": "npm:2.1.7"
"@rspack/binding-win32-x64-msvc": "npm:2.1.7"
dependenciesMeta:
"@rspack/binding-darwin-arm64":
optional: true
@@ -4894,15 +4954,15 @@ __metadata:
optional: true
"@rspack/binding-win32-x64-msvc":
optional: true
checksum: 10/74c73ac543059fa62365557ca949ef7a84e1cebc42fe51a343126c875d90f557b35a52b33bacd9498d9231e8fdb8ab13907988e1fd39131ffbd56becb4260785
checksum: 10/1400d8553d2122850fc43a45b69828bcdcbccf7715381a627802e160da6f93e90ca41d0280a69d175894bc29a428a374c80157b874883363b998090dc0aff33c
languageName: node
linkType: hard
"@rspack/core@npm:2.1.8":
version: 2.1.8
resolution: "@rspack/core@npm:2.1.8"
"@rspack/core@npm:2.1.7":
version: 2.1.7
resolution: "@rspack/core@npm:2.1.7"
dependencies:
"@rspack/binding": "npm:2.1.8"
"@rspack/binding": "npm:2.1.7"
peerDependencies:
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
"@swc/helpers": ^0.5.23
@@ -4911,7 +4971,7 @@ __metadata:
optional: true
"@swc/helpers":
optional: true
checksum: 10/c818257225a24feb22af81eb3ed7d02d4b74d6e6cb70689c8cbcd34e3facfcfe833dfe4078e0a35277e926f431cae9fd7f2101c3944b396a72baa7cf3ce99d19
checksum: 10/84526df889fa2813cfa7dfbed71b713c47c2010b1f55da26db698a87ea11f3f433a931ec29616ff300cb63d0ea27cbe7d2b40df64abf45f9b1a484b59c94ca1a
languageName: node
linkType: hard
@@ -4927,9 +4987,9 @@ __metadata:
languageName: node
linkType: hard
"@rspack/dev-server@npm:2.2.0":
version: 2.2.0
resolution: "@rspack/dev-server@npm:2.2.0"
"@rspack/dev-server@npm:2.1.0":
version: 2.1.0
resolution: "@rspack/dev-server@npm:2.1.0"
dependencies:
"@rspack/dev-middleware": "npm:^2.0.3"
peerDependencies:
@@ -4938,7 +4998,7 @@ __metadata:
peerDependenciesMeta:
selfsigned:
optional: true
checksum: 10/edfc57223fad64c43c96be95b0b6650194ac2e02d929bef649d524faa9c618233afe8f970ac2e21c77e1b92f6c65dac20f06817e73a9bd661f43c73057ae6b6b
checksum: 10/402d4f96c60beaba354081a36b053c3cf7c1dda7fddab791031dc4206b9873b98437895d5a6a68247e07cb8a5922a1770143c560772dfdaa00ba90a5396251d8
languageName: node
linkType: hard
@@ -5771,105 +5831,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.66.0"
"@typescript-eslint/eslint-plugin@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.65.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/type-utils": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
"@typescript-eslint/scope-manager": "npm:8.65.0"
"@typescript-eslint/type-utils": "npm:8.65.0"
"@typescript-eslint/utils": "npm:8.65.0"
"@typescript-eslint/visitor-keys": "npm:8.65.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.66.0
"@typescript-eslint/parser": ^8.65.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/bb144ff0c27592b6a53d964fe1e2145dd0737f5fe50fe7dcd88217ca5ac4e470d7965d97745a63de16252ac2880d00732a472b9cec14d7d6929ebda97e7bcbb1
checksum: 10/20b1e5fd0c01d450c345750582e4911affef8ba934b2b78953ff593102d2a01f21c5f6469e13239fdd6a0e30152d6122906e7623f53aa8c937c6faae9407be47
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/parser@npm:8.66.0"
"@typescript-eslint/parser@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/parser@npm:8.65.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
"@typescript-eslint/scope-manager": "npm:8.65.0"
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/typescript-estree": "npm:8.65.0"
"@typescript-eslint/visitor-keys": "npm:8.65.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/a0451abe17d2eeff6ff2e1bf637d5ea19b400378d98f4511672e0f30dc5a7971d03352e32764927176c3632f245d5f85e8b28a0a3e66d020effcf0c61624e9b6
checksum: 10/55f68666953c02c8adae35a46076848da6456181b1849a28ec836a5866ca37b902f475fb4c32ba7aa3a6a7e5d66828df8859edec297da09181fb937aeea30f8e
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/project-service@npm:8.66.0"
"@typescript-eslint/project-service@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/project-service@npm:8.65.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.66.0"
"@typescript-eslint/types": "npm:^8.66.0"
"@typescript-eslint/tsconfig-utils": "npm:^8.65.0"
"@typescript-eslint/types": "npm:^8.65.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/e8a71f69ee7f4bd9ca1c6a0a5110361b9927d17df2f41cdf6d042203b49aabfbddece79dec7882fde8dc016b6278671a2ba3f7bf08e5ae241582627b68be3a8b
checksum: 10/915662449a66d90f03661a805f7c62a5efaa8f7887671272e08b684b41edab51a9021f47aac4d78001a0f87960e8587adc037424d56f13de883e0ce699e7ca55
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/scope-manager@npm:8.66.0"
"@typescript-eslint/scope-manager@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/scope-manager@npm:8.65.0"
dependencies:
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
checksum: 10/f2024cd2819dc3b967f32089ea1a0b9099acabf12860699432f0ba802d75b01ed35ea488a50576cae544813d5cbe9bd9ee5815bc4f11cefc89fe2fb24559238f
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/visitor-keys": "npm:8.65.0"
checksum: 10/038e208c907aa45fe5bb7168e1dccf89c1fc6678d1715a9c04f4b332d4e79ab719b5b43a4e0c2d5a183ea863813ff59fda040b2717fe5517468453af6b99a50a
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.66.0, @typescript-eslint/tsconfig-utils@npm:^8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.66.0"
"@typescript-eslint/tsconfig-utils@npm:8.65.0, @typescript-eslint/tsconfig-utils@npm:^8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.65.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/271b28660edc9e6272ae07a91f8da2c69e8a6b009e8588f1009c485c11fe4e66e09356d50dbeabf8e6a44c95c8adeb630f1b6089bdd68aec2512d0036cef7704
checksum: 10/f88253a4df1d599a1bebeeb403611538485e22c52213f2f2b9c435bde8ebce64645d6bb985c900b01ef221032835fcd4f6fea4b94d178220c18de5d93af905c4
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/type-utils@npm:8.66.0"
"@typescript-eslint/type-utils@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/type-utils@npm:8.65.0"
dependencies:
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/typescript-estree": "npm:8.65.0"
"@typescript-eslint/utils": "npm:8.65.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/6d3c55a591b96cbac4ac845ad1d281bac54df12a631ca550370d266aed30760fa9d498e44b938e454941930e3decda820c6e544e0748254c5089425d120e1ed1
checksum: 10/d52e0c341c9731d8f3bfd2f475bbcd5a5632434e11fc39e8e21acb706145bedb8c6c1998b8ced73e132b43179dd95f2c318effc36c9a1dd61f14546b07b25852
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.66.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/types@npm:8.66.0"
checksum: 10/de1ea79e7056c11e38e4001a899b88511793a0aa1702508af42ee179bc7303bed2eb02f177a7ae438f20eab00aee9407c7e842bec6fc5b7bb1fdcab4a7cd5c4b
"@typescript-eslint/types@npm:8.65.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/types@npm:8.65.0"
checksum: 10/a6fc10a733adbb98bbb9c312c8d99791e1a2fd3efef5c2a5b51da1c166aac3db4427c30bf32059808abe305a289f820bf610683914e897baeb18315af6a1d16c
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/typescript-estree@npm:8.66.0"
"@typescript-eslint/typescript-estree@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/typescript-estree@npm:8.65.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.66.0"
"@typescript-eslint/tsconfig-utils": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
"@typescript-eslint/project-service": "npm:8.65.0"
"@typescript-eslint/tsconfig-utils": "npm:8.65.0"
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/visitor-keys": "npm:8.65.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -5877,32 +5937,32 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/0834efcdd4468c0dd954089daf26b40175e204402bd4075b06cd910786bb6de7a15aec4e79e40f0980f39fce5bfd9c871ec8fcc6871a8895a34a273bb187fd26
checksum: 10/711fdb5eff67ff34437c56a1a661b16a2e1d6bcd96c9f96196c4242b8d4ddaea8e835ca8badd340c703e043d8dfe8cfca90b32eca60069a4f1d2880afdb670fb
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/utils@npm:8.66.0"
"@typescript-eslint/utils@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/utils@npm:8.65.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/scope-manager": "npm:8.65.0"
"@typescript-eslint/types": "npm:8.65.0"
"@typescript-eslint/typescript-estree": "npm:8.65.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/e7a24ee0f35d58b4392daa03fb1ea37ab7e4519949a034be89151df1b0e9fc416831f50cb69f54150272e252cba350964632ee9bfbdb7e62ac599cc731de56e1
checksum: 10/c1dcd555b58aef1e066164978335e521809acac36b56bd6a6dae62cffae80f3ea5f43527506be76dfef0fe3d4c8382a24355a28867c4904b0a7729691ba45656
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/visitor-keys@npm:8.66.0"
"@typescript-eslint/visitor-keys@npm:8.65.0":
version: 8.65.0
resolution: "@typescript-eslint/visitor-keys@npm:8.65.0"
dependencies:
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.65.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/d47e936b10ec94a96f69f77a62463f4634fe72d12d42dd302d7777f7b98689468e0b67c538a52232b723695208f1b3fdeae5745eb3e71a70a90818fdd827a0bc
checksum: 10/e7f86d21f0bf03ca7cff9fa9428aab98620564d15fb06c9f56a294c13cee324624d42f9115f3d76fbad92266220479cca334b5c66562f885da5c4d2bda3d87b3
languageName: node
linkType: hard
@@ -7501,6 +7561,15 @@ __metadata:
languageName: node
linkType: hard
"chart2music@npm:1.20.0, chart2music@npm:^1.20.0":
version: 1.20.0
resolution: "chart2music@npm:1.20.0"
dependencies:
"@formatjs/intl": "npm:3.1.6"
checksum: 10/ed421708740e3644a72356c9f313d3bcb7cac26d94a7fb209fa4bc8f9bc24061ed63f98da89277684e71f9402b5414cd45bb60f01c01e576803ba3dca755f4ba
languageName: node
linkType: hard
"chokidar@npm:^3.5.3":
version: 3.6.0
resolution: "chokidar@npm:3.6.0"
@@ -8098,7 +8167,7 @@ __metadata:
languageName: node
linkType: hard
"decimal.js@npm:^10.6.0":
"decimal.js@npm:^10.4.3, decimal.js@npm:^10.6.0":
version: 10.6.0
resolution: "decimal.js@npm:10.6.0"
checksum: 10/c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69
@@ -8364,6 +8433,17 @@ __metadata:
languageName: node
linkType: hard
"echarts-extension-chart2music@npm:0.1.0":
version: 0.1.0
resolution: "echarts-extension-chart2music@npm:0.1.0"
dependencies:
chart2music: "npm:^1.20.0"
peerDependencies:
echarts: ">=5.0.0 <7"
checksum: 10/55a82c770355228b2a899007e488e5b60171cbaf5d83363a298c4214d9e4a5df1afd1c7805495f42f2ad6edd7c062c8b366f1f8ba7b1a902f2db7eb47fbfcccf
languageName: node
linkType: hard
"echarts@npm:6.1.0":
version: 6.1.0
resolution: "echarts@npm:6.1.0"
@@ -9936,8 +10016,8 @@ __metadata:
"@playwright/test": "npm:1.62.1"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.6.1"
"@rspack/core": "npm:2.1.8"
"@rspack/dev-server": "npm:2.2.0"
"@rspack/core": "npm:2.1.7"
"@rspack/dev-server": "npm:2.1.0"
"@swc/helpers": "npm:0.5.23"
"@thomasloven/round-slider": "npm:0.6.0"
"@tsparticles/engine": "npm:4.3.2"
@@ -9967,6 +10047,7 @@ __metadata:
barcode-detector: "npm:3.2.1"
browserslist-useragent-regexp: "npm:4.1.4"
cally: "npm:0.9.2"
chart2music: "npm:1.20.0"
color-name: "npm:2.1.1"
comlink: "npm:4.4.2"
core-js: "npm:3.49.0"
@@ -9978,6 +10059,7 @@ __metadata:
del: "npm:8.0.1"
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.0"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.8.0"
eslint-config-prettier: "npm:10.1.8"
@@ -10017,7 +10099,7 @@ __metadata:
lodash.template: "npm:4.18.1"
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
marked: "npm:18.0.9"
marked: "npm:18.0.7"
memoize-one: "npm:6.0.0"
minify-literals: "npm:2.1.0"
node-vibrant: "npm:4.0.4"
@@ -10040,7 +10122,7 @@ __metadata:
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.66.0"
typescript-eslint: "npm:8.65.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.10"
webpack-stats-plugin: "npm:1.1.3"
@@ -10349,6 +10431,18 @@ __metadata:
languageName: node
linkType: hard
"intl-messageformat@npm:10.7.16":
version: 10.7.16
resolution: "intl-messageformat@npm:10.7.16"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
"@formatjs/fast-memoize": "npm:2.2.7"
"@formatjs/icu-messageformat-parser": "npm:2.11.2"
tslib: "npm:^2.8.0"
checksum: 10/c19b77c5e495ce8b0d1aa0d95444bf3a4f73886805f1e08d7159b364abcf2f63686b2ccf202eaafb0e39a0e9fde61848b8dd2db1679efd4f6ec8f6a3d0e77928
languageName: node
linkType: hard
"intl-messageformat@npm:11.2.13":
version: 11.2.13
resolution: "intl-messageformat@npm:11.2.13"
@@ -11660,12 +11754,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:18.0.9":
version: 18.0.9
resolution: "marked@npm:18.0.9"
"marked@npm:18.0.7":
version: 18.0.7
resolution: "marked@npm:18.0.7"
bin:
marked: bin/marked.js
checksum: 10/99d337c50acd57034734f8460f25b28e9658b15627f950092707cb443b840f0bd29fe343bf211b948e643303c34abdb5b5a12cf5245a846635750697524bb747
checksum: 10/7ea7b8556a9e8cab2881b194815a7550c61d77639c6b8f8d9022f96790fd2b289b2ea28969cea6e01c24c3b8ec822373288ee02b7c50f178bc3ec79aa42d05f1
languageName: node
linkType: hard
@@ -14892,18 +14986,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.66.0":
version: 8.66.0
resolution: "typescript-eslint@npm:8.66.0"
"typescript-eslint@npm:8.65.0":
version: 8.65.0
resolution: "typescript-eslint@npm:8.65.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.66.0"
"@typescript-eslint/parser": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
"@typescript-eslint/eslint-plugin": "npm:8.65.0"
"@typescript-eslint/parser": "npm:8.65.0"
"@typescript-eslint/typescript-estree": "npm:8.65.0"
"@typescript-eslint/utils": "npm:8.65.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/e76da9c684e4fe012b49a5120dd57553df0788cf1cffcbd5dc72c98de33e549f306495e710292f111ee058366b1e5d8d603c2bb14385b12bb35edc7bbf13ad55
checksum: 10/5b2242f59005afdd57190849be7df2e86ce33b007fa0bf605f700ad0e38ea14fc14c48deafdf4a039614c1f012b71c61a19edb27f76d52fdc924cde476a100d1
languageName: node
linkType: hard