From a56e934841a995f6dfa8b34028d5c3a96a900b96 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:25:08 +0000 Subject: [PATCH 001/154] Update ghcr.io/element-hq/element-web:develop Docker digest to e29b408 (#4215) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 3ed06606..514bb757 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -13,7 +13,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:133160c3c1e506276145cbb8ccaf48253f0a96af635129d785826265e4f3ca10 + image: ghcr.io/element-hq/element-web:develop@sha256:e29b408ce1959506962952356e6772cb8052dc6a04dcc8e0b3572026ee8fad6f element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:133160c3c1e506276145cbb8ccaf48253f0a96af635129d785826265e4f3ca10 + image: ghcr.io/element-hq/element-web:develop@sha256:e29b408ce1959506962952356e6772cb8052dc6a04dcc8e0b3572026ee8fad6f From 6ddcc15206632eccb2410d7fa34f3cdd48f5ad6a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:09:28 +0000 Subject: [PATCH 002/154] Update ghcr.io/element-hq/element-web:develop Docker digest to e8727bb (#4216) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 514bb757..46ea4699 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -13,7 +13,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:e29b408ce1959506962952356e6772cb8052dc6a04dcc8e0b3572026ee8fad6f + image: ghcr.io/element-hq/element-web:develop@sha256:e8727bbb67bbde23c2abf3b0f29ac005379ffe40de5e1f34591177ca17538fc0 element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:e29b408ce1959506962952356e6772cb8052dc6a04dcc8e0b3572026ee8fad6f + image: ghcr.io/element-hq/element-web:develop@sha256:e8727bbb67bbde23c2abf3b0f29ac005379ffe40de5e1f34591177ca17538fc0 From 256ff7a0b6ea8ffd9d13866d531df635131de88d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:40:41 +0000 Subject: [PATCH 003/154] Update dependency matrix-widget-api to v1.19.0 --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d8f89ca..4cfb3ccd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,7 +195,7 @@ importers: version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a matrix-widget-api: specifier: ^1.18.0 - version: 1.18.0 + version: 1.19.0 node-stdlib-browser: specifier: ^1.3.1 version: 1.3.1 @@ -4690,8 +4690,8 @@ packages: version: 42.2.0 engines: {node: '>=22.0.0'} - matrix-widget-api@1.18.0: - resolution: {integrity: sha512-4T2f2koWmx05p1BLcT/9YGGGPSXpPT+PA4Oap/5fjhXsWPxMGJiGL97YpANMYLKsnE1sScYFeuyxIgdc1Qo+Ew==} + matrix-widget-api@1.19.0: + resolution: {integrity: sha512-Qe7L6G1mQJphBC47/Bzp5ljPIK1UARgLiuGgS+/KmEs/eaNHkQ3+ArD0/0kvMV+d6kCGAZ9OKRqsnuPjizI+tw==} md5.js@1.3.5: resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} @@ -10284,12 +10284,12 @@ snapshots: content-type: 3.0.0 loglevel: 1.9.2 matrix-events-sdk: 0.0.1 - matrix-widget-api: 1.18.0 + matrix-widget-api: 1.19.0 p-retry: 8.0.0 sdp-transform: 3.0.0 unhomoglyph: 1.0.6 - matrix-widget-api@1.18.0: + matrix-widget-api@1.19.0: dependencies: '@types/events': 3.0.3 events: 3.3.0 From 9330b907fc621a68610a5ff092d90dec589fdfe3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:49:32 +0000 Subject: [PATCH 004/154] Update ghcr.io/element-hq/element-web:develop Docker digest to 7c5eb96 (#4218) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 46ea4699..3a87c4b2 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -13,7 +13,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:e8727bbb67bbde23c2abf3b0f29ac005379ffe40de5e1f34591177ca17538fc0 + image: ghcr.io/element-hq/element-web:develop@sha256:7c5eb96364aea52a107bad92f332ba6e9091c601b89da369ddbbf6df35d1d672 element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:e8727bbb67bbde23c2abf3b0f29ac005379ffe40de5e1f34591177ca17538fc0 + image: ghcr.io/element-hq/element-web:develop@sha256:7c5eb96364aea52a107bad92f332ba6e9091c601b89da369ddbbf6df35d1d672 From efdee7dcdbba49d8b06b8e030e7cd9fcbe867783 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:53:52 +0000 Subject: [PATCH 005/154] Update ghcr.io/element-hq/element-web:develop Docker digest to c107ec3 (#4221) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 3a87c4b2..edc88cd8 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -13,7 +13,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:7c5eb96364aea52a107bad92f332ba6e9091c601b89da369ddbbf6df35d1d672 + image: ghcr.io/element-hq/element-web:develop@sha256:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:7c5eb96364aea52a107bad92f332ba6e9091c601b89da369ddbbf6df35d1d672 + image: ghcr.io/element-hq/element-web:develop@sha256:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f From 7485b3d71f49fe21a9d64352f19c3d2ba33709d2 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 1 Sep 2026 18:30:05 +0200 Subject: [PATCH 006/154] Use a dedicated i18next instance instead of the global singleton Element Call configured the global i18next singleton. When Element Call runs embedded in a host application rather than as its own page, that singleton belongs to the host, so configuring it would clobber the host's translations. Create Element Call's own instance in utils/i18n.ts, configure it in the initializer, and pass it to components via . Drop .use(initReactI18next) from the initializer: it registers the instance as react-i18next's global default, which is the global we are trying to avoid. Tests and stories keep using it, so that they do not need to wrap every render in a provider. Two modules imported `t` directly from "i18next" and so were bound to the global instance: utils/errors.ts now calls i18n.t() on the instance (reached at call time, since i18next only assigns `t` during init), and QrCode uses useTranslation() like every other component. No functional change. --- .storybook/preview.tsx | 6 ++++-- src/App.tsx | 34 +++++++++++++++++-------------- src/QrCode.tsx | 3 ++- src/initializer.tsx | 8 +++++--- src/utils/errors.ts | 45 +++++++++++++++++++++--------------------- src/utils/i18n.ts | 21 ++++++++++++++++++++ src/vitest.setup.ts | 6 ++++-- 7 files changed, 77 insertions(+), 46 deletions(-) diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 757c1f8a..5b841b82 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -7,14 +7,16 @@ Please see LICENSE in the repository root for full details. import type { Preview } from "@storybook/react-vite"; import { TooltipProvider } from "@vector-im/compound-web"; -import i18n from "i18next"; import { logger } from "matrix-js-sdk/lib/logger"; import EN from "../locales/en/app.json"; import { initReactI18next } from "react-i18next"; +import { i18n } from "../src/utils/i18n"; import "../src/index.css"; -// Bare-minimum i18n config +// Bare-minimum i18n config. +// Unlike the app, stories register the instance as react-i18next's default +// rather than wrapping every story in an . i18n .use(initReactI18next) .init({ diff --git a/src/App.tsx b/src/App.tsx index 8f6ef21a..36afc4c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/lib/logger"; +import { I18nextProvider } from "react-i18next"; import { HomePage } from "./home/HomePage"; import { LoginPage } from "./auth/LoginPage"; @@ -32,6 +33,7 @@ import { type AppViewModel } from "./state/AppViewModel"; import { MediaDevicesContext } from "./MediaDevicesContext"; import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams"; import { AppBar } from "./AppBar"; +import { i18n } from "./utils/i18n"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -98,20 +100,22 @@ export const App: FC = ({ vm }) => { ); return ( - - - - - - {header === HeaderStyle.AppBar ? ( - {content} - ) : ( - content - )} - - - - - + + + + + + + {header === HeaderStyle.AppBar ? ( + {content} + ) : ( + content + )} + + + + + + ); }; diff --git a/src/QrCode.tsx b/src/QrCode.tsx index 09bd92ea..da569395 100644 --- a/src/QrCode.tsx +++ b/src/QrCode.tsx @@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details. import { type FC, useEffect, useState } from "react"; import { toDataURL } from "qrcode"; import classNames from "classnames"; -import { t } from "i18next"; +import { useTranslation } from "react-i18next"; import styles from "./QrCode.module.css"; @@ -18,6 +18,7 @@ interface Props { } export const QrCode: FC = ({ data, className }) => { + const { t } = useTranslation(); const [url, setUrl] = useState(null); useEffect(() => { diff --git a/src/initializer.tsx b/src/initializer.tsx index 91436d10..c0e8407e 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details. */ import React from "react"; -import i18n, { +import { type BackendModule, type ReadCallback, type ResourceKey, } from "i18next"; -import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import * as Sentry from "@sentry/react"; import { logger } from "matrix-js-sdk/lib/logger"; @@ -35,6 +34,7 @@ import { platform } from "./Platform"; import { isFailure } from "./utils/fetch"; import { initializeWidget } from "./widget"; import { enableExtendedLivekitLogs } from "./settings/settings.ts"; +import { i18n } from "./utils/i18n.ts"; // This generates a map of locale names to their URL (based on import.meta.url), which looks like this: // { @@ -148,10 +148,12 @@ export class Initializer { document.documentElement.lang = lng; }); + // Note: deliberately no `.use(initReactI18next)` — that would register this + // instance as react-i18next's global default, which is the very global we + // are avoiding. Components receive it through `` instead. await i18n .use(Backend) .use(languageDetector) - .use(initReactI18next) .init({ fallbackLng: "en", defaultNS: "app", diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 0ac56927..73b904c9 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { t } from "i18next"; import { type ConnectionError } from "livekit-client"; -import { i18nKey } from "./i18n"; +import { i18n, i18nKey } from "./i18n"; export enum ErrorCode { /** @@ -78,10 +77,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError { */ public constructor(domain: string) { super( - t("error.call_is_not_supported"), + i18n.t("error.call_is_not_supported"), ErrorCode.MISSING_MATRIX_RTC_TRANSPORT, ErrorCategory.CONFIGURATION_ISSUE, - t("error.matrix_rtc_transport_missing", { + i18n.t("error.matrix_rtc_transport_missing", { domain, brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call", errorCode: ErrorCode.MISSING_MATRIX_RTC_TRANSPORT, @@ -97,10 +96,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError { export class ConnectionLostError extends ElementCallError { public constructor() { super( - t("error.connection_lost"), + i18n.t("error.connection_lost"), ErrorCode.CONNECTION_LOST_ERROR, ErrorCategory.NETWORK_CONNECTIVITY, - t("error.connection_lost_description"), + i18n.t("error.connection_lost_description"), ); } } @@ -117,10 +116,10 @@ export class MembershipManagerError extends ElementCallError { */ public constructor(error: Error) { super( - t("error.membership_manager"), + i18n.t("error.membership_manager"), ErrorCode.INTERNAL_MEMBERSHIP_MANAGER, ErrorCategory.SYSTEM_FAILURE, - t("error.membership_manager_description"), + i18n.t("error.membership_manager_description"), error, ); } @@ -134,10 +133,10 @@ export class MembershipManagerError extends ElementCallError { export class StickyEventsRequiredError extends ElementCallError { public constructor() { super( - t("error.sticky_events_required"), + i18n.t("error.sticky_events_required"), ErrorCode.STICKY_EVENTS_NOT_SUPPORTED, ErrorCategory.CONFIGURATION_ISSUE, - t("error.sticky_events_required_description"), + i18n.t("error.sticky_events_required_description"), ); } } @@ -148,10 +147,10 @@ export class StickyEventsRequiredError extends ElementCallError { export class E2EENotSupportedError extends ElementCallError { public constructor() { super( - t("error.e2ee_unsupported"), + i18n.t("error.e2ee_unsupported"), ErrorCode.E2EE_NOT_SUPPORTED, ErrorCategory.CLIENT_CONFIGURATION, - t("error.e2ee_unsupported_description"), + i18n.t("error.e2ee_unsupported_description"), ); } } @@ -166,7 +165,7 @@ export class UnknownCallError extends ElementCallError { */ public constructor(error: Error) { super( - t("error.generic"), + i18n.t("error.generic"), ErrorCode.UNKNOWN_ERROR, ErrorCategory.UNKNOWN, undefined, @@ -186,7 +185,7 @@ export class FailToGetOpenIdToken extends ElementCallError { */ public constructor(error: Error) { super( - t("error.generic"), + i18n.t("error.generic"), ErrorCode.OPEN_ID_ERROR, ErrorCategory.CONFIGURATION_ISSUE, undefined, @@ -203,10 +202,10 @@ export class NoMatrix2AuthorizationService extends ElementCallError { */ public constructor(error: Error) { super( - t("error.generic"), + i18n.t("error.generic"), ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE, ErrorCategory.CONFIGURATION_ISSUE, - t("error.no_matrix_2_authorization_service"), + i18n.t("error.no_matrix_2_authorization_service"), // Properly set it as a cause for a better reporting on sentry error, ); @@ -223,7 +222,7 @@ export class FailToStartLivekitConnection extends ElementCallError { */ public constructor(e?: string) { super( - t("error.failed_to_start_livekit"), + i18n.t("error.failed_to_start_livekit"), ErrorCode.FAILED_TO_START_LIVEKIT, ErrorCategory.NETWORK_CONNECTIVITY, e, @@ -237,10 +236,10 @@ export class FailToStartLivekitConnection extends ElementCallError { export class InsufficientCapacityError extends ElementCallError { public constructor() { super( - t("error.insufficient_capacity"), + i18n.t("error.insufficient_capacity"), ErrorCode.INSUFFICIENT_CAPACITY_ERROR, ErrorCategory.UNKNOWN, - t("error.insufficient_capacity_description"), + i18n.t("error.insufficient_capacity_description"), ); } } @@ -252,10 +251,10 @@ export class InsufficientCapacityError extends ElementCallError { export class SFURoomCreationRestrictedError extends ElementCallError { public constructor() { super( - t("error.room_creation_restricted"), + i18n.t("error.room_creation_restricted"), ErrorCode.SFU_ERROR, ErrorCategory.CONFIGURATION_ISSUE, - t("error.room_creation_restricted_description"), + i18n.t("error.room_creation_restricted_description"), ); } } @@ -266,7 +265,7 @@ export class SFURoomCreationRestrictedError extends ElementCallError { export class PeerConnectionTimeoutError extends ElementCallError { public constructor() { super( - t("error.peer_connection_timeout"), + i18n.t("error.peer_connection_timeout"), ErrorCode.SFU_ERROR, ErrorCategory.NETWORK_CONNECTIVITY, ); @@ -283,7 +282,7 @@ export class PeerConnectionTimeoutError extends ElementCallError { export class LivekitConnectionError extends ElementCallError { public constructor(cause: ConnectionError) { super( - t("error.livekit_connection_error"), + i18n.t("error.livekit_connection_error"), ErrorCode.SFU_ERROR, ErrorCategory.NETWORK_CONNECTIVITY, ); diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 51bf2fb6..abe28f54 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -5,5 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +import i18next, { type i18n as I18nInstance } from "i18next"; + // Custom marker function to allow i18next extraction export const i18nKey = (key: string): string => key; + +/** + * Element Call's own i18next instance. + * + * We deliberately do not use the global i18next singleton: when Element Call is + * embedded in a host application (rather than running as its own page), that + * singleton belongs to the host, and configuring it would clobber the host's + * translations. + * + * It is configured by `Initializer.initBeforeReact` and made available to + * components via ``; tests and stories configure it directly. + * + * Non-React code should call `i18n.t(...)` on this instance. Components should + * use `useTranslation()` instead, so that they re-render on a language change. + * Note that `t` must be reached through the instance at call time — i18next + * only assigns it during `init()`, so destructuring it at module scope would + * capture an uninitialised function. + */ +export const i18n: I18nInstance = i18next.createInstance(); diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index be7179de..00d289b5 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details. import "@formatjs/intl-durationformat/polyfill.js"; import "@formatjs/intl-segmenter/polyfill"; -import i18n from "i18next"; import posthog from "posthog-js"; import { initReactI18next } from "react-i18next"; import { afterEach } from "vitest"; @@ -18,8 +17,11 @@ import "@testing-library/jest-dom/vitest"; import EN from "../locales/en/app.json"; import { Config } from "./config/Config"; +import { i18n } from "./utils/i18n"; -// Bare-minimum i18n config +// Bare-minimum i18n config. +// Unlike the app, tests register the instance as react-i18next's default rather +// than wrapping every render in an . i18n .use(initReactI18next) .init({ From 0a8c24bca86870862c37ac1139e8d2cd13fac71d Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 1 Sep 2026 18:40:53 +0200 Subject: [PATCH 007/154] Allow the config to be supplied by an embedder Config.init() derives the location of config.json from window.location, which only makes sense while Element Call owns the page. When it is embedded in a host application the host owns the configuration, so add Config.initWith() to accept it directly. Share the defaulting and validation between both paths via resolveConfig(), so that an injected config behaves identically to a fetched one, and mark initialization as complete so that the init() calls already on the startup path resolve immediately instead of fetching over the top of it. initDefault() becomes initWith({}), which also stops it handing out a shallow copy of DEFAULT_CONFIG whose nested objects were shared with the module-level default. --- src/config/Config.test.ts | 53 +++++++++++++++++++++++++++++++++++++-- src/config/Config.ts | 49 ++++++++++++++++++++++++++++++------ 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/config/Config.test.ts b/src/config/Config.test.ts index 34dd44cb..5f8b09a2 100644 --- a/src/config/Config.test.ts +++ b/src/config/Config.test.ts @@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details. import { describe, expect, it, vi, afterEach } from "vitest"; import { logger } from "matrix-js-sdk/lib/logger"; -import { validateConfig } from "./Config"; -import { MatrixRTCMode } from "./ConfigOptions"; +import { Config, validateConfig } from "./Config"; +import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions"; describe("validateConfig", () => { afterEach(() => { @@ -52,3 +52,52 @@ describe("validateConfig", () => { expect(result.ssla).toBe("https://example.invalid/ssla"); }); }); + +describe("Config.initWith", () => { + // vitest.setup.ts has already called initDefault(), so every test here is + // free to re-initialize; the last call wins. + afterEach(() => { + vi.restoreAllMocks(); + Config.initDefault(); + }); + + it("makes the supplied config readable", () => { + Config.initWith({ ssla: "https://example.invalid/ssla" }); + expect(Config.get().ssla).toBe("https://example.invalid/ssla"); + }); + + it("fills in defaults for keys the embedder did not supply", () => { + Config.initWith({ ssla: "https://example.invalid/ssla" }); + expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality); + }); + + it("validates the supplied config just as a fetched one would be", () => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + Config.initWith({ + matrix_rtc_mode: "nonsense" as unknown as MatrixRTCMode, + }); + expect(Config.get().matrix_rtc_mode).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("does not share nested state with DEFAULT_CONFIG", () => { + Config.initWith({}); + expect(Config.get().media_quality).not.toBe(DEFAULT_CONFIG.media_quality); + }); + + it("stops a later init() from fetching over the top of it", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + Config.initWith({ ssla: "https://example.invalid/ssla" }); + + await Config.init(); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(Config.get().ssla).toBe("https://example.invalid/ssla"); + }); + + it("replaces a config initialized earlier", () => { + Config.initWith({ ssla: "https://first.invalid/ssla" }); + Config.initWith({ ssla: "https://second.invalid/ssla" }); + expect(Config.get().ssla).toBe("https://second.invalid/ssla"); + }); +}); diff --git a/src/config/Config.ts b/src/config/Config.ts index f52b28fd..37cdfc61 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -30,6 +30,14 @@ export class Config { return this.internalInstance.config; } + /** + * Initializes the config by fetching `config.json`, locating it relative to + * the current page. + * + * Does nothing if the config has already been initialized, including by + * {@link Config.initWith}, so that the regular startup path can run unchanged + * when an embedder has already supplied the config. + */ public static async init(): Promise { if (!Config.internalInstance?.initPromise) { const internalInstance = new Config(); @@ -50,17 +58,36 @@ export class Config { Config.internalInstance.initPromise = downloadConfig(fetchTarget).then( (config) => { - internalInstance.config = merge( - {}, - DEFAULT_CONFIG, - validateConfig(config), - ); + internalInstance.config = resolveConfig(config); }, ); } return Config.internalInstance.initPromise; } + /** + * Initializes the config from an object supplied by the embedder, instead of + * fetching `config.json`. + * + * {@link Config.init} derives the location of `config.json` from + * `window.location`, which only makes sense while Element Call owns the page. + * When it is embedded in a host application the host owns the configuration + * and passes it in here. + * + * The config goes through the same validation and defaulting as a fetched + * one, so that an injected config behaves identically to a hosted one. + * + * Replaces any config initialized earlier. + */ + public static initWith(config: ConfigOptions): void { + const internalInstance = new Config(); + internalInstance.config = resolveConfig(config); + // Mark initialization as already done, so that a later init() resolves + // immediately rather than fetching config.json over the top of this. + internalInstance.initPromise = Promise.resolve(); + Config.internalInstance = internalInstance; + } + /** * This is a alternative initializer that does not load anything * from a hosted config file but instead just initializes the config using the @@ -69,8 +96,7 @@ export class Config { * It is supposed to only be used in tests. (It is executed in `vite.setup.js`) */ public static initDefault(): void { - Config.internalInstance = new Config(); - Config.internalInstance.config = { ...DEFAULT_CONFIG }; + Config.initWith({}); } // Convenience accessors @@ -94,6 +120,15 @@ export class Config { private initPromise?: Promise; } +/** + * Applies validation and the built-in defaults to a config, however it was + * obtained. Deep-merges onto a fresh object so that the result never shares + * nested state with {@link DEFAULT_CONFIG}. + */ +function resolveConfig(config: ConfigOptions): ResolvedConfigOptions { + return merge({}, DEFAULT_CONFIG, validateConfig(config)); +} + export function validateConfig(config: ConfigOptions): ConfigOptions { const mode = config.matrix_rtc_mode; if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) { From 0dca5835f6087f0b2df0ab989ac4c96adb83b759 Mon Sep 17 00:00:00 2001 From: Valere Date: Tue, 1 Sep 2026 18:54:27 +0200 Subject: [PATCH 008/154] Route Element Call's DOM decoration through an injectable root element Element Call writes its theme classes, background and layout attributes straight onto document.body, and portals its modals there too. That is only correct while it owns the page; embedded in a host application it has to confine itself to the container it was mounted into. Add a RootElementContext, defaulting to document.body so that the standalone and widget builds are unaffected, and point the theme classes, data-background, no-scroll-body and the fullscreen target at it. Give the Modal and Toast portals an explicit container as well. Radix and vaul both default to document.body, so without this every modal, drawer and toast would render outside the container and lose the theme and platform attributes set on it. No functional change: the root element is document.body until an embedder provides otherwise. --- src/App.tsx | 8 ++++---- src/Modal.tsx | 6 ++++-- src/RootElementContext.ts | 30 ++++++++++++++++++++++++++++++ src/Toast.tsx | 8 +++++++- src/room/GroupCallView.tsx | 8 +++++--- src/tile/SpotlightTile.tsx | 8 ++++---- src/useTheme.ts | 12 +++++++----- 7 files changed, 61 insertions(+), 19 deletions(-) create mode 100644 src/RootElementContext.ts diff --git a/src/App.tsx b/src/App.tsx index 36afc4c2..55703f4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import { MediaDevicesContext } from "./MediaDevicesContext"; import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams"; import { AppBar } from "./AppBar"; import { i18n } from "./utils/i18n"; +import { useRootElement } from "./RootElementContext"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -44,12 +45,11 @@ interface SimpleProviderProps { const BackgroundProvider: FC = ({ children }) => { const { pathname } = useLocation(); const { background } = useUrlParams(); + const rootElement = useRootElement(); useEffect(() => { - document - .getElementsByTagName("body")[0] - .setAttribute("data-background", background); - }, [pathname, background]); + rootElement.setAttribute("data-background", background); + }, [pathname, background, rootElement]); return children; }; diff --git a/src/Modal.tsx b/src/Modal.tsx index e6ffdf45..46316d37 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -24,6 +24,7 @@ import { Heading, Glass } from "@vector-im/compound-web"; import styles from "./Modal.module.css"; import overlayStyles from "./Overlay.module.css"; import { useMediaQuery } from "./useMediaQuery"; +import { useRootElement } from "./RootElementContext"; export interface Props { title: string; @@ -78,6 +79,7 @@ export const Modal: FC = ({ ...rest }) => { const { t } = useTranslation(); + const rootElement = useRootElement(); // Empirically, Chrome on Android can end up not matching (hover: none), but // still matching (pointer: coarse) :/ const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)"); @@ -100,7 +102,7 @@ export const Modal: FC = ({ onOpenChange={onOpenChange} dismissible={onDismiss !== undefined} > - + = ({ return ( - + diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts new file mode 100644 index 00000000..b97b1779 --- /dev/null +++ b/src/RootElementContext.ts @@ -0,0 +1,30 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { createContext, use } from "react"; + +/** + * The element that Element Call treats as the root of its own interface. + * + * Element Call decorates this element with the theme, layout and background + * attributes its stylesheets key off, and portals its modals into it. When + * Element Call owns the page this is simply the document body; when it is + * embedded in a host application it is the container the host mounted it into, + * so that Element Call does not reach outside its own subtree. + */ +const RootElementContext = createContext(null); + +export const RootElementProvider = RootElementContext.Provider; + +/** + * The element Element Call should decorate and portal into. + * + * Defaults to the document body, so that the standalone and widget builds work + * without a provider. + */ +export const useRootElement = (): HTMLElement => + use(RootElementContext) ?? document.body; diff --git a/src/Toast.tsx b/src/Toast.tsx index 83e220bc..0a2a2f1b 100644 --- a/src/Toast.tsx +++ b/src/Toast.tsx @@ -25,6 +25,7 @@ import { Text } from "@vector-im/compound-web"; import styles from "./Toast.module.css"; import overlayStyles from "./Overlay.module.css"; +import { useRootElement } from "./RootElementContext"; interface Props { /** @@ -64,6 +65,7 @@ export const Toast: FC = ({ Icon, modal = true, }) => { + const rootElement = useRootElement(); const onOpenChange = useCallback( (open: boolean) => { if (!open) onDismiss(); @@ -104,7 +106,11 @@ export const Toast: FC = ({ return ( - {modal ? {content} : content} + {modal ? ( + {content} + ) : ( + content + )} ); }; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index fbd589e7..dfeb0866 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -80,6 +80,7 @@ import { useTypedEventEmitter } from "../useEvents"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useAppBarTitle } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; +import { useRootElement } from "../RootElementContext.ts"; /** * If there already are this many participants in the call, we automatically mute @@ -123,6 +124,7 @@ export const GroupCallView: FC = ({ null, ); const memberships = useMatrixRTCSessionMemberships(rtcSession); + const rootElement = useRootElement(); const muteAllAudio = useBehavior(muteAllAudio$); const leaveSoundContext = useLatest( @@ -150,11 +152,11 @@ export const GroupCallView: FC = ({ // viewport sizes smaller than 122px width. (It is actually this exact number: 122px // tested on different devices...) useEffect(() => { - document.body.classList.add("no-scroll-body"); + rootElement.classList.add("no-scroll-body"); return (): void => { - document.body.classList.remove("no-scroll-body"); + rootElement.classList.remove("no-scroll-body"); }; - }, []); + }, [rootElement]); useEffect(() => { window.rtcSession = rtcSession; diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 036e044f..97e1f4a6 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -54,6 +54,7 @@ import { Slider } from "../Slider"; import { platform } from "../Platform"; import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel"; import { RingingStatus } from "./RingingStatus"; +import { useRootElement } from "../RootElementContext"; interface SpotlightItemBaseProps { ref?: Ref; @@ -414,6 +415,7 @@ export const SpotlightTile: FC = ({ style, }) => { const { t } = useTranslation(); + const rootElement = useRootElement(); const [ourRef, root$] = useObservableRef(null); const ref = useMergedRefs(ourRef, theirRef); const maximised = useBehavior(vm.maximised$); @@ -428,24 +430,22 @@ export const SpotlightTile: FC = ({ const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1; const isFullscreen = useCallback((): boolean => { - const rootElement = document.body; if (rootElement && document.fullscreenElement) return true; return false; - }, []); + }, [rootElement]); const FullScreenIcon = isFullscreen() ? FullScreenMinimiseIcon : FullScreenMaximiseIcon; const onToggleFullscreen = useCallback(() => { - const rootElement = document.body; if (!rootElement) return; if (isFullscreen()) { void document?.exitFullscreen(); } else { void rootElement.requestFullscreen(); } - }, [isFullscreen]); + }, [isFullscreen, rootElement]); // To keep track of which item is visible, we need an intersection observer // hooked up to the root element and the items. Because the items will run diff --git a/src/useTheme.ts b/src/useTheme.ts index e992aee7..5bac2898 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -11,12 +11,14 @@ import { type IThemeChangeActionRequest } from "matrix-widget-api"; import { getUrlParams } from "./UrlParams"; import { widget } from "./widget"; +import { useRootElement } from "./RootElementContext"; export const useTheme = (): void => { + const rootElement = useRootElement(); const [requestedTheme, setRequestedTheme] = useState( () => getUrlParams().theme, ); - const previousTheme = useRef(document.body.classList.item(0)); + const previousTheme = useRef(rootElement.classList.item(0)); useEffect(() => { if (widget) { @@ -47,15 +49,15 @@ export const useTheme = (): void => { : ""; const themeString = "cpd-theme-" + theme + themeHighContrast; if (themeString !== previousTheme.current) { - document.body.classList.remove( + rootElement.classList.remove( "cpd-theme-light", "cpd-theme-dark", "cpd-theme-light-hc", "cpd-theme-dark-hc", ); - document.body.classList.add(themeString); + rootElement.classList.add(themeString); previousTheme.current = themeString; } - document.body.classList.remove("no-theme"); - }, [previousTheme, requestedTheme]); + rootElement.classList.remove("no-theme"); + }, [previousTheme, requestedTheme, rootElement]); }; From bc6015eff2454b0735b78d4a3f0c1e61522a6791 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 09:58:34 +0200 Subject: [PATCH 009/154] Supply Element Call's parameters through a context Element Call's parameters come from its URL, which works while it owns the page but leaves an embedder with nowhere to put them. Add a context so they can be provided directly, falling back to parsing window.location when no provider is present. This also decouples the thirteen consumers from react-router: useUrlParams called useLocation, so each of them required a router ancestor, which the embedded build will not have. The standalone and widget builds keep their URL-derived behaviour via useUrlParamsFromLocation, provided in App. No functional change. --- src/App.tsx | 46 ++++++++++++++++++++++++++++++++-------------- src/UrlParams.ts | 33 +++++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 55703f4c..511577de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,7 +31,13 @@ import { useTheme } from "./useTheme"; import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { type AppViewModel } from "./state/AppViewModel"; import { MediaDevicesContext } from "./MediaDevicesContext"; -import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams"; +import { + getUrlParams, + HeaderStyle, + UrlParamsProvider, + useUrlParams, + useUrlParamsFromLocation, +} from "./UrlParams"; import { AppBar } from "./AppBar"; import { i18n } from "./utils/i18n"; import { useRootElement } from "./RootElementContext"; @@ -42,6 +48,16 @@ interface SimpleProviderProps { children: JSX.Element; } +/** + * Supplies the URL-derived params to the rest of the app. Only the standalone + * and widget builds own the URL, so this lives here in the app shell rather + * than alongside the context itself. + */ +const LocationUrlParamsProvider: FC = ({ children }) => { + const urlParams = useUrlParamsFromLocation(); + return {children}; +}; + const BackgroundProvider: FC = ({ children }) => { const { pathname } = useLocation(); const { background } = useUrlParams(); @@ -102,19 +118,21 @@ export const App: FC = ({ vm }) => { return ( - - - - - {header === HeaderStyle.AppBar ? ( - {content} - ) : ( - content - )} - - - - + + + + + + {header === HeaderStyle.AppBar ? ( + {content} + ) : ( + content + )} + + + + + ); diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 805cab71..dd83a496 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { useMemo } from "react"; +import { createContext, use, useMemo } from "react"; import { useLocation } from "react-router-dom"; import { logger } from "matrix-js-sdk/lib/logger"; import { @@ -519,11 +519,36 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { }; }; +const UrlParamsContext = createContext(null); + /** - * Hook to simplify use of getUrlParams. - * @returns The app parameters for the current URL + * Supplies the parameters Element Call should run with. + * + * The standalone and widget builds derive these from the URL, but an embedder + * has no URL of its own to put them in, so it provides them directly instead. + * + * TODO: `UrlParams` is no longer an accurate name now that these need not come + * from a URL. Renaming it touches every consumer, so it is left until the rest + * of the de-globalisation work has settled. */ -export const useUrlParams = (): UrlParams => { +export const UrlParamsProvider = UrlParamsContext.Provider; + +/** + * The parameters Element Call is running with. + * + * Falls back to parsing `window.location` when no provider is present, so that + * tests and stories keep working without one. + */ +export const useUrlParams = (): UrlParams => + use(UrlParamsContext) ?? getUrlParams(); + +/** + * Derives {@link UrlParams} from the current router location. + * + * Only meaningful when Element Call owns the URL; embedders provide the params + * directly through {@link UrlParamsProvider}. + */ +export const useUrlParamsFromLocation = (): UrlParams => { const { search, hash } = useLocation(); return useMemo(() => getUrlParams(search, hash), [search, hash]); }; From 6e44332fd1ada38406a6890f0b77fe7267ea50b6 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 10:31:48 +0200 Subject: [PATCH 010/154] Stop reading URL parameters from inside the call path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view models reached for getUrlParams() — and so window.location — from deep inside the call path: CallViewModel, MediaDevices, Publisher, LocalMember and the footer view model. An embedded Element Call has no URL of its own, so these values have to arrive as arguments instead. Add the relevant options to CallViewModelOptions, to the MediaDevices and Publisher constructors, to createLocalMembership$ and enterRTCSession, and to createCallFooterViewModel. The remaining React consumers read the context added in the previous commit. AppViewModel now takes its audio output options too, moving that URL read out to main.tsx, where the app shell can act as the adapter. The new CallViewModelOptions fields are optional, defaulting to what the URL parameters resolve to outside widget mode; the MediaDevices and Publisher arguments are required, so that every construction site has to be explicit. useTheme.test.ts mocked the UrlParams module with a factory, so it needed updating to mock the hook rather than getUrlParams. No functional change. --- sdk/main.ts | 13 ++++-- src/App.tsx | 25 ++++------- src/components/CallFooter.stories.tsx | 4 +- src/components/CallFooterViewModel.test.ts | 3 ++ src/components/CallFooterViewModel.tsx | 7 ++- .../MediaMuteAndSwitchButton.stories.tsx | 4 +- src/main.tsx | 11 ++++- src/room/GroupCallView.tsx | 10 ++--- src/room/InCallView.tsx | 19 +++++++- src/state/AppViewModel.ts | 12 +++-- src/state/CallViewModel/CallViewModel.test.ts | 5 ++- src/state/CallViewModel/CallViewModel.ts | 44 ++++++++++++++++--- .../localMember/LocalMember.test.ts | 1 + .../CallViewModel/localMember/LocalMember.ts | 26 ++++++++--- .../localMember/Publisher.test.ts | 3 ++ .../CallViewModel/localMember/Publisher.ts | 6 ++- src/state/MediaDevices.ts | 31 ++++++++++--- src/useTheme.test.ts | 8 ++-- src/useTheme.ts | 7 ++- src/utils/test-viewmodel.ts | 2 + 20 files changed, 176 insertions(+), 65 deletions(-) diff --git a/sdk/main.ts b/sdk/main.ts index a001af65..55aa4a02 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -116,7 +116,7 @@ export async function createMatrixRTCSdk( logger.info("client created"); // url params - const { roomId } = getUrlParams(); + const { roomId, controlledAudioDevices, callIntent } = getUrlParams(); if (roomId === null) throw Error("could not get roomId from url params"); const room = client.getRoom(roomId); if (room === null) throw Error("could not get room from client"); @@ -128,7 +128,10 @@ export async function createMatrixRTCSdk( const rtcSession = rtcSessionManager.getRoomSession(room); // media devices - const mediaDevices = new MediaDevices(scope); + const mediaDevices = new MediaDevices(scope, { + controlledAudioDevices, + callIntent, + }); const muteStates = new MuteStates(scope, mediaDevices, { audioEnabled: false, videoEnabled: false, @@ -141,7 +144,11 @@ export async function createMatrixRTCSdk( room, mediaDevices, muteStates, - { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } }, + { + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + controlledAudioDevices, + callIntent, + }, of({}), of({}), constant({ supported: false, processor: undefined }), diff --git a/src/App.tsx b/src/App.tsx index 511577de..238815a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,14 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - type FC, - type JSX, - Suspense, - useEffect, - useMemo, - useState, -} from "react"; +import { type FC, type JSX, Suspense, useEffect, useState } from "react"; import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; @@ -32,7 +25,6 @@ import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { type AppViewModel } from "./state/AppViewModel"; import { MediaDevicesContext } from "./MediaDevicesContext"; import { - getUrlParams, HeaderStyle, UrlParamsProvider, useUrlParams, @@ -75,6 +67,12 @@ const ThemeProvider: FC = ({ children }) => { return children; }; +/** Wraps the app in an {@link AppBar}, if the params ask for one. */ +const MaybeAppBar: FC = ({ children }) => { + const { header } = useUrlParams(); + return header === HeaderStyle.AppBar ? {children} : children; +}; + interface Props { vm: AppViewModel; } @@ -91,9 +89,6 @@ export const App: FC = ({ vm }) => { .catch(logger.error); }); - // Since we are outside the router component, we cannot use useUrlParams here - const { header } = useMemo(getUrlParams, []); - const content = loaded ? ( @@ -123,11 +118,7 @@ export const App: FC = ({ vm }) => { - {header === HeaderStyle.AppBar ? ( - {content} - ) : ( - content - )} + {content} diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 667cb607..3c3f4607 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -29,7 +29,9 @@ const reactionData = { reactions$: new BehaviorSubject({}), }; -const mediaDevices = new MediaDevices(globalScope); +const mediaDevices = new MediaDevices(globalScope, { + controlledAudioDevices: false, +}); /** * A wrapper component that is used for: diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts index 1fc9187a..9e73393b 100644 --- a/src/components/CallFooterViewModel.test.ts +++ b/src/components/CallFooterViewModel.test.ts @@ -15,6 +15,7 @@ import type { Alignment, Layout } from "../state/layout-types"; import type { SpotlightTileViewModel } from "../state/TileViewModel"; import type { DeviceLabel } from "../state/MediaDevices"; import { createCallFooterViewModel } from "./CallFooterViewModel"; +import { HeaderStyle } from "../UrlParams"; const platformMock = vi.hoisted(() => vi.fn(() => "desktop")); vi.mock("../Platform", () => ({ @@ -105,6 +106,7 @@ describe("createCallFooterViewModel", () => { mockMuteStates(), twoMicsAndOneCamMediaDevices, /* reactionIdentifier */ undefined, + { showControls: true, header: HeaderStyle.Standard }, ); expect(vm.audioOptions$.value).toEqual([]); @@ -126,6 +128,7 @@ describe("createCallFooterViewModel", () => { mockMuteStates(), twoMicsAndOneCamMediaDevices, /* reactionIdentifier */ undefined, + { showControls: true, header: HeaderStyle.Standard }, ); expect(vm.audioOptions$?.value).toEqual([ diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index 7e391b16..a2ca6c88 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -19,7 +19,7 @@ import { type Behavior, constant } from "../state/Behavior"; import type { ObservableScope } from "../state/ObservableScope"; import { type MuteStates } from "../state/MuteStates"; import { createStaticViewModel, type ViewModel } from "../state/ViewModel"; -import { getUrlParams, HeaderStyle } from "../UrlParams"; +import { HeaderStyle } from "../UrlParams"; import { platform } from "../Platform"; import { type FooterSnapshot } from "./CallFooter"; @@ -138,6 +138,8 @@ function buildDeviceBehaviors( * @param mediaDevices - Available and selected input devices. * @param reactionIdentifier - The local user's reaction identifier string, or * undefined when reactions are not supported (hides the reaction button). + * @param options - `showControls`: whether the call controls should be shown. + * `header`: the style of header, which decides whether to show the logo. */ export function createCallFooterViewModel( scope: ObservableScope, @@ -145,8 +147,9 @@ export function createCallFooterViewModel( muteStates: MuteStates, mediaDevices: MediaDevices, reactionIdentifier: string | undefined, + options: { showControls: boolean; header: HeaderStyle }, ): ViewModel { - const { showControls, header: headerStyle } = getUrlParams(); + const { showControls, header: headerStyle } = options; const showLogo = headerStyle === HeaderStyle.Standard; const isPip$ = scope.behavior( diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index 89c12392..21def400 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -14,7 +14,9 @@ import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevices } from "../state/MediaDevices"; import { globalScope } from "../state/ObservableScope"; -const mediaDevices = new MediaDevices(globalScope); +const mediaDevices = new MediaDevices(globalScope, { + controlledAudioDevices: false, +}); const meta = { component: MediaMuteAndSwitchButton, diff --git a/src/main.tsx b/src/main.tsx index 8f64c680..03c74c94 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,6 +21,7 @@ import { init as initRageshake } from "./settings/rageshake"; import { Initializer } from "./initializer"; import { AppViewModel } from "./state/AppViewModel"; import { globalScope } from "./state/ObservableScope"; +import { getUrlParams } from "./UrlParams"; initRageshake().catch((e) => { logger.error("Failed to initialize rageshake", e); @@ -49,9 +50,17 @@ if (fatalError !== null) { Initializer.initBeforeReact() .then(() => { + const { controlledAudioDevices, callIntent } = getUrlParams(); root.render( - + , ); }) diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index dfeb0866..57e9205f 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -54,12 +54,7 @@ import { useRoomAvatar } from "./useRoomAvatar"; import { useRoomName } from "./useRoomName"; import { useJoinRule } from "./useJoinRule"; import { InviteModal } from "./InviteModal"; -import { - getUrlParams, - HeaderStyle, - type UrlParams, - useUrlParams, -} from "../UrlParams"; +import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams"; import { E2eeType } from "../e2ee/e2eeType"; import { useAudioContext } from "../useAudioContext"; import { @@ -406,7 +401,7 @@ export const GroupCallView: FC = ({ } // On a normal user hangup we can shut down and close the widget. But if an // error occurs we should keep the widget open until the user reads it. - if (reason != "error" && !getUrlParams().returnToLobby) { + if (reason != "error" && !returnToLobby) { try { await widget.api.transport.send(ElementWidgetActions.Close, {}); } catch (e) { @@ -425,6 +420,7 @@ export const GroupCallView: FC = ({ rtcSession, isPasswordlessUser, confineToRoom, + returnToLobby, navigate, ], ); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index a57dcce2..e291e001 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -122,8 +122,16 @@ export const ActiveCall: FC = (props) => { rootLogger.info("START CALL VIEW SCOPE"); const scope = new ObservableScope(); const reactionsReader = new ReactionsReader(scope, props.rtcSession); - const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } = - urlParams; + const { + autoLeaveWhenOthersLeft, + waitForCallPickup, + sendNotificationType, + controlledAudioDevices, + header, + showControls, + hideScreensharing, + callIntent, + } = urlParams; const vm = createCallViewModel$( scope, @@ -136,6 +144,12 @@ export const ActiveCall: FC = (props) => { autoLeaveWhenOthersLeft, waitForCallPickup: waitForCallPickup && sendNotificationType === "ring", matrixRTCMode$: matrixRTCModeSetting.value$, + controlledAudioDevices, + header, + showControls, + hideScreensharing, + sendNotificationType, + callIntent, }, reactionsReader.raisedHands$, reactionsReader.reactions$, @@ -172,6 +186,7 @@ export const ActiveCall: FC = (props) => { props.muteStates, mediaDevices, `${props.client.getUserId()}:${props.client.getDeviceId()}`, + { showControls: urlParams.showControls, header: urlParams.header }, ); setFooterVm(footerVm); setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm)); diff --git a/src/state/AppViewModel.ts b/src/state/AppViewModel.ts index 7ad91e9d..3f69515b 100644 --- a/src/state/AppViewModel.ts +++ b/src/state/AppViewModel.ts @@ -5,17 +5,23 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { MediaDevices } from "./MediaDevices"; +import { type AudioOutputOptions, MediaDevices } from "./MediaDevices"; import { type ObservableScope } from "./ObservableScope"; /** * The top-level state holder for the application. */ export class AppViewModel { - public readonly mediaDevices = new MediaDevices(this.scope); + public readonly mediaDevices = new MediaDevices( + this.scope, + this.audioOutputOptions, + ); // TODO: Move more application logic here. The CallViewModel, at the very // least, ought to be accessible from this object. - public constructor(private readonly scope: ObservableScope) {} + public constructor( + private readonly scope: ObservableScope, + private readonly audioOutputOptions: AudioOutputOptions, + ) {} } diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 18154917..7a27fd3e 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -1593,12 +1593,13 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { it.skip("audio output changes when toggling earpiece mode", () => { withTestScheduler(({ schedule, expectObservable }) => { - getUrlParams.mockReturnValue({ controlledAudioDevices: true }); vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue( of([]), ); - const devices = new MediaDevices(testScope()); + const devices = new MediaDevices(testScope(), { + controlledAudioDevices: true, + }); window.controls.setAvailableAudioDevices([ { id: "speaker", name: "Speaker", isSpeaker: true }, diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 09f73d1a..e9abeae5 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -45,6 +45,8 @@ import { MembershipManagerEvent, type LivekitTransportConfig, type MatrixRTCSession, + type RTCCallIntent, + type RTCNotificationType, } from "matrix-js-sdk/lib/matrixrtc"; import { type IWidgetApiRequest } from "matrix-widget-api"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; @@ -85,7 +87,7 @@ import { constant, type Behavior } from "../Behavior"; import { E2eeType } from "../../e2ee/e2eeType"; import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider"; import { type MuteStates } from "../MuteStates"; -import { getUrlParams, HeaderStyle } from "../../UrlParams"; +import { HeaderStyle } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { ElementWidgetActions, widget } from "../../widget"; import { @@ -171,6 +173,23 @@ import { type GridTileViewModel } from "../TileViewModel.ts"; // callMembership -> rtcMembership export interface CallViewModelOptions { encryptionSystem: EncryptionSystem; + /** + * Whether the app hosting Element Call controls the audio output devices, + * rather than the browser. Defaults to false. + */ + controlledAudioDevices?: boolean; + /** The style of header to show. Defaults to {@link HeaderStyle.Standard}. */ + header?: HeaderStyle; + /** Whether the call controls should be shown. Defaults to true. */ + showControls?: boolean; + /** Whether to hide the screen-sharing button. Defaults to false. */ + hideScreensharing?: boolean; + /** + * Whether and what kind of notification to send when joining the call. + */ + sendNotificationType?: RTCNotificationType; + /** The kind of call being placed. */ + callIntent?: RTCCallIntent; autoLeaveWhenOthersLeft?: boolean; /** * If the call is started in a way where we want it to behave like a telephone usecase @@ -435,6 +454,17 @@ export function createCallViewModel$( if (!(userId && deviceId)) throw new UnknownCallError(new Error("userId and deviceId are required")); + // Defaults match what the URL parameters resolve to outside of widget mode, + // so that callers which don't care (chiefly tests) behave as they always have. + const { + controlledAudioDevices = false, + header = HeaderStyle.Standard, + showControls = true, + hideScreensharing = false, + sendNotificationType, + callIntent, + } = options; + const livekitKeyProvider = getE2eeKeyProvider( options.encryptionSystem, matrixRTCSession, @@ -523,7 +553,7 @@ export function createCallViewModel$( mediaDevices, trackProcessorState$, livekitKeyProvider, - getUrlParams().controlledAudioDevices, + controlledAudioDevices, options.livekitRoomFactory, ); @@ -563,6 +593,8 @@ export function createCallViewModel$( encryptMedia: livekitKeyProvider !== undefined, // TODO. This might need to get called again on each change of matrixRTCMode... matrixRTCMode: mode, + sendNotificationType, + callIntent, })), ), ); @@ -592,12 +624,14 @@ export function createCallViewModel$( logger.getChild( "[Publisher " + connection.transport.livekit_service_url + "]", ), + controlledAudioDevices, ); }, connectionManager, matrixRTCSession, localTransport$, roomId: matrixRoom.roomId, + hideScreensharing, logger: logger.getChild(`[${Date.now()}]`), }); @@ -1457,9 +1491,8 @@ export function createCallViewModel$( ), ); - const urlParams = getUrlParams(); const showFooterUrlParams = !( - urlParams.header === HeaderStyle.None && urlParams.showControls === false + header === HeaderStyle.None && showControls === false ); const showFooter$ = scope.behavior( naturallyShowFooter$.pipe( @@ -1778,8 +1811,7 @@ export function createCallViewModel$( return { autoLeave$: autoLeave$, ringingVm$: ringingMedia$, - ringingStatusLocation: - urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile", + ringingStatusLocation: header === HeaderStyle.AppBar ? "app_bar" : "tile", leave$: leave$, hangup: (): void => userHangup$.next(), join: localMembership.requestJoinAndPublish, diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 9ea6bb72..2b89d50b 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -199,6 +199,7 @@ describe("LocalMembership", () => { rtsSession$: constant(RTCMemberStatus.Connected), }, roomId: "!test-room-id:example.org", + hideScreensharing: false, }; it("throws error on missing RTC config error", () => { diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 2f4fde26..844b66de 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -20,6 +20,8 @@ import { type LivekitTransport, type LivekitTransportConfig, type MatrixRTCSession, + type RTCCallIntent, + type RTCNotificationType, } from "matrix-js-sdk/lib/matrixrtc"; import { BehaviorSubject, @@ -52,7 +54,7 @@ import { UnknownCallError, } from "../../../utils/errors.ts"; import { ElementWidgetActions, widget } from "../../../widget.ts"; -import { getUrlParams } from "../../../UrlParams.ts"; + import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; import { advancedScreenShare, @@ -141,6 +143,8 @@ interface Props { MatrixRTCSession, "updateCallIntent" | "leaveRoomSession" >; + /** Whether to hide the screen-sharing button. */ + hideScreensharing: boolean; logger: Logger; } @@ -160,6 +164,7 @@ interface Props { * @param props.muteStates The mute states for video and audio. * @param props.matrixRTCSession The matrix RTC session to join. * @param props.roomId The room ID used as the call identifier in analytics events. + * @param props.hideScreensharing Whether to hide the screen-sharing button. * @returns * - publisher: The handle to create tracks and publish them to the room. * - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication) @@ -178,6 +183,7 @@ export const createLocalMembership$ = ({ muteStates, matrixRTCSession, roomId, + hideScreensharing, }: Props): { /** * This request to start audio and video tracks. @@ -709,7 +715,7 @@ export const createLocalMembership$ = ({ let toggleScreenSharing: (() => void) | null = null; if ( "getDisplayMedia" in (navigator.mediaDevices ?? {}) && - !getUrlParams().hideScreensharing + !hideScreensharing ) { toggleScreenSharing = (): void => { const screenshareSettings: ScreenShareCaptureOptions = { @@ -820,6 +826,10 @@ export function observeSharingScreen$(p: Participant): Observable { interface EnterRTCSessionOptions { encryptMedia: boolean; matrixRTCMode: MatrixRTCMode; + /** Whether and what kind of notification to send when joining. */ + sendNotificationType?: RTCNotificationType; + /** The kind of call being placed. */ + callIntent?: RTCCallIntent; } /** @@ -832,7 +842,9 @@ interface EnterRTCSessionOptions { * @param rtcSession - The MatrixRTCSession to join. * @param ownMembershipIdentity - Options for entering the RTC session. * @param transport - The LivekitTransport to use for this session. - * @param options - `encryptMedia`: Whether to encrypt media `matrixRTCMode`: The Matrix RTC mode to use. + * @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The + * Matrix RTC mode to use. `sendNotificationType`: Whether and what kind of + * notification to send on join. `callIntent`: The kind of call being placed. * @throws If the widget could not send ElementWidgetActions.JoinCall action. */ // Exported for unit testing @@ -842,7 +854,12 @@ export function enterRTCSession( transport: LivekitTransportConfig, options: EnterRTCSessionOptions, ): void { - const { encryptMedia, matrixRTCMode } = options; + const { + encryptMedia, + matrixRTCMode, + sendNotificationType: notificationType, + callIntent, + } = options; PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId); @@ -851,7 +868,6 @@ export function enterRTCSession( // groupCallOTelMembership?.onJoinCall(); const { matrix_rtc_session: matrixRtcSessionConfig } = Config.get(); - const { sendNotificationType: notificationType, callIntent } = getUrlParams(); const multiSFU = matrixRTCMode === MatrixRTCMode.Compatibility || matrixRTCMode === MatrixRTCMode.Matrix_2_0; diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index 21775c58..5d9f0378 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -192,6 +192,7 @@ describe("Publisher", () => { muteStates, constant({ supported: false, processor: undefined }), logger, + false, ); }); @@ -309,6 +310,7 @@ describe("Publisher", () => { muteStates, constant({ supported: false, processor: undefined }), logger, + false, ); }); afterEach(async () => { @@ -364,6 +366,7 @@ describe("Bug fix", () => { muteStates, constant({ supported: false, processor: undefined }), logger, + false, ); audioEnabled$.next(true); diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 0d5f263a..5353a98d 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -29,7 +29,7 @@ import { type ProcessorState, trackProcessorSync, } from "../../../livekit/TrackProcessorContext.tsx"; -import { getUrlParams } from "../../../UrlParams.ts"; + import { observeTrackReference$ } from "../../observeTrackReference"; import { type Connection } from "../remoteMembers/Connection.ts"; import { ObservableScope } from "../../ObservableScope.ts"; @@ -56,6 +56,8 @@ export class Publisher { * @param muteStates - The mute states for audio and video. * @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur). * @param logger - The logger to use for logging :D. + * @param controlledAudioDevices - Whether the app hosting Element Call + * controls the audio output devices, rather than the browser. */ public constructor( private connection: Pick, //setE2EEEnabled, @@ -63,8 +65,8 @@ export class Publisher { private readonly muteStates: MuteStates, trackerProcessorState$: Behavior, private logger: Logger, + controlledAudioDevices: boolean, ) { - const { controlledAudioDevices } = getUrlParams(); const room = connection.livekitRoom; room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => { diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 70a676cf..4610bab6 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -16,6 +16,7 @@ import { } from "rxjs"; import { createMediaDeviceObserver } from "@livekit/components-core"; import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger"; +import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; import { alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, @@ -25,7 +26,6 @@ import { } from "../settings/settings"; import { type ObservableScope } from "./ObservableScope"; import { availableOutputDevices$ as controlledAvailableOutputDevices$ } from "../controls"; -import { getUrlParams } from "../UrlParams"; import { platform } from "../Platform"; import { switchWhen } from "../utils/observable"; import { type Behavior, constant } from "./Behavior"; @@ -338,6 +338,22 @@ class VideoInput implements MediaDevice { } } +/** + * How Element Call should manage audio output. + */ +export interface AudioOutputOptions { + /** + * Whether the list of output devices is controlled by the app hosting Element + * Call, through the global JS controls, rather than by the browser. + */ + controlledAudioDevices: boolean; + /** + * The kind of call being placed, which decides the initial output route when + * the host controls the devices. + */ + callIntent?: RTCCallIntent; +} + export class MediaDevices { private readonly deviceNamesRequest$ = new Subject(); /** @@ -368,23 +384,28 @@ export class MediaDevices { public readonly audioOutput: MediaDevice< AudioOutputDeviceLabel, SelectedAudioOutputDevice - > = getUrlParams().controlledAudioDevices + > = this.audioOutputOptions.controlledAudioDevices ? platform == "android" ? new AndroidControlledAudioOutput( controlledAvailableOutputDevices$, this.scope, - getUrlParams().callIntent, + this.audioOutputOptions.callIntent, window.controls, ) : new IOSControlledAudioOutput( this.usingNames$, this.scope, - getUrlParams().callIntent, + this.audioOutputOptions.callIntent, ) : new AudioOutput(this.usingNames$, this.scope); public readonly videoInput: MediaDevice = new VideoInput(this.usingNames$, this.scope); - public constructor(private readonly scope: ObservableScope) {} + // Note: both parameters are read by the field initializers above, which is + // safe because TypeScript assigns parameter properties before running them. + public constructor( + private readonly scope: ObservableScope, + private readonly audioOutputOptions: AudioOutputOptions, + ) {} } diff --git a/src/useTheme.test.ts b/src/useTheme.test.ts index 6e471462..4078ed34 100644 --- a/src/useTheme.test.ts +++ b/src/useTheme.test.ts @@ -19,10 +19,10 @@ import EventEmitter from "events"; import { WidgetApiToWidgetAction } from "matrix-widget-api"; import { useTheme } from "./useTheme"; -import { getUrlParams } from "./UrlParams"; +import { useUrlParams } from "./UrlParams"; import { widget } from "./widget"; -vi.mock("./UrlParams", () => ({ getUrlParams: vi.fn() })); +vi.mock("./UrlParams", () => ({ useUrlParams: vi.fn() })); vi.mock("./widget", () => ({ widget: { api: { transport: { reply: vi.fn() } }, @@ -39,7 +39,7 @@ describe("useTheme", () => { vi.spyOn(originalClassList, "add"); vi.spyOn(originalClassList, "remove"); vi.spyOn(originalClassList, "item").mockReturnValue(null); - (getUrlParams as Mock).mockReturnValue({ theme: "dark" }); + (useUrlParams as Mock).mockReturnValue({ theme: "dark" }); }); afterEach(() => { @@ -53,7 +53,7 @@ describe("useTheme", () => { { setTheme: "light-high-contrast", add: ["cpd-theme-light-hc"] }, ])("apply procedure", ({ setTheme, add }) => { test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => { - (getUrlParams as Mock).mockReturnValue({ theme: setTheme }); + (useUrlParams as Mock).mockReturnValue({ theme: setTheme }); renderHook(() => useTheme()); diff --git a/src/useTheme.ts b/src/useTheme.ts index 5bac2898..85dace2a 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -9,15 +9,14 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { WidgetApiToWidgetAction } from "matrix-widget-api"; import { type IThemeChangeActionRequest } from "matrix-widget-api"; -import { getUrlParams } from "./UrlParams"; +import { useUrlParams } from "./UrlParams"; import { widget } from "./widget"; import { useRootElement } from "./RootElementContext"; export const useTheme = (): void => { const rootElement = useRootElement(); - const [requestedTheme, setRequestedTheme] = useState( - () => getUrlParams().theme, - ); + const { theme } = useUrlParams(); + const [requestedTheme, setRequestedTheme] = useState(theme); const previousTheme = useRef(rootElement.classList.item(0)); useEffect(() => { diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index f5391002..8e88e720 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -40,6 +40,7 @@ import { type RaisedHandInfo, type ReactionInfo } from "../reactions"; import { constant } from "../state/Behavior"; import { MatrixRTCMode } from "../config/ConfigOptions"; import { createCallFooterViewModel } from "../components/CallFooterViewModel"; +import { HeaderStyle } from "../UrlParams"; import { type FooterSnapshot } from "../components/CallFooter"; import { type ViewModel } from "../state/ViewModel"; import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel"; @@ -187,6 +188,7 @@ export function getBasicCallViewModelEnvironment( muteStates, mediaDevices, "reactionId", + { showControls: true, header: HeaderStyle.Standard }, ); return { vm, From d8d70812b847fa54c3b1adc9e3d787d5a3270443 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 11:00:42 +0200 Subject: [PATCH 011/154] Read the shared room key from the parameters context useRoomEncryptionSystem is used from GroupCallView, inside the part of Element Call that will become the embeddable component, but it reached for getUrlParams() via getKeyForRoom(). Extract the lookup into a helper taking the room ID and password explicitly: the hook supplies them from the parameters context, while getKeyForRoom keeps reading the URL for its one remaining caller in the app shell. No functional change. --- src/e2ee/sharedKeyManagement.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/e2ee/sharedKeyManagement.ts b/src/e2ee/sharedKeyManagement.ts index 18d007e2..b29ede31 100644 --- a/src/e2ee/sharedKeyManagement.ts +++ b/src/e2ee/sharedKeyManagement.ts @@ -12,7 +12,7 @@ import { setLocalStorageItemReactive, useLocalStorage, } from "../useLocalStorage"; -import { getUrlParams } from "../UrlParams"; +import { getUrlParams, useUrlParams } from "../UrlParams"; import { E2eeType } from "./e2eeType"; import { useClient } from "../ClientContext"; @@ -57,19 +57,31 @@ const useRoomSharedKey = ( return [setInitialValue ?? roomSharedKey, setRoomSharedKey]; }; -export function getKeyForRoom(roomId: string): string | null { - const { roomId: urlRoomId, password } = getUrlParams(); - if (roomId !== urlRoomId) +/** + * The shared key for a room, preferring one supplied in the parameters Element + * Call was started with over whatever is in local storage. + */ +function keyForRoom( + roomId: string, + paramsRoomId: string | null, + password: string | null, +): string | null { + if (roomId !== paramsRoomId) logger.warn( "requested key for a roomId which is not the current call room id (from the URL)", roomId, - urlRoomId, + paramsRoomId, ); return ( password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId)) ); } +export function getKeyForRoom(roomId: string): string | null { + const { roomId: paramsRoomId, password } = getUrlParams(); + return keyForRoom(roomId, paramsRoomId, password); +} + export type Unencrypted = { kind: E2eeType.NONE }; export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string }; export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT }; @@ -77,10 +89,15 @@ export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE; export function useRoomEncryptionSystem(roomId: string): EncryptionSystem { const { client } = useClient(); + const { roomId: paramsRoomId, password } = useUrlParams(); const [storedPassword] = useRoomSharedKey( + // TODO: this passes an already-prefixed key where a room ID is expected, so + // the local storage key ends up prefixed twice and never matches what + // saveKeyForRoom writes. Preserved as-is here to keep this commit a pure + // refactor; the reactive read is effectively dead until it is fixed. getRoomSharedKeyLocalStorageKey(roomId), - getKeyForRoom(roomId) ?? undefined, + keyForRoom(roomId, paramsRoomId, password) ?? undefined, ); const room = client?.getRoom(roomId); From 8e78ac76bd8df8631763e4e9af23901352c08000 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 13:03:23 +0200 Subject: [PATCH 012/154] Drop ErrorView's widget prop in favour of the host bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ErrorView needed a widget only to decide whether to offer a close button or a link home. That prop was threaded down through ErrorPage, RichError and GroupCallErrorBoundary from seven call sites, to answer the single question of whether the host can dismiss Element Call — which the host bridge now answers directly through the presence of close(). Read the bridge from context in ErrorView and remove the prop, along with the plumbing that carried it. No functional change: the rendered output is unchanged, as the existing snapshot confirms. Move GroupCallView onto the host bridge GroupCallView asked the widget API to keep it on screen, to close it, and to tell it when a preloaded call should join. Route all three through the host bridge and drop the widget prop. --- src/App.tsx | 43 ++-- src/Avatar.test.tsx | 63 ++---- src/Avatar.tsx | 34 +-- src/ClientContext.tsx | 2 +- src/ErrorView.tsx | 32 ++- src/FullScreenView.tsx | 5 +- src/HostBridge.test.ts | 101 +++++++++ src/HostBridge.ts | 214 ++++++++++++++++++ src/RichError.tsx | 7 +- src/home/HomePage.tsx | 3 +- src/room/GroupCallErrorBoundary.test.tsx | 58 ++--- src/room/GroupCallErrorBoundary.tsx | 9 +- src/room/GroupCallView.test.tsx | 132 ++++------- src/room/GroupCallView.tsx | 120 +++++----- src/room/RoomPage.tsx | 7 +- .../GroupCallErrorBoundary.test.tsx.snap | 2 +- src/useTheme.test.ts | 43 ++-- src/useTheme.ts | 32 +-- 18 files changed, 542 insertions(+), 365 deletions(-) create mode 100644 src/HostBridge.test.ts create mode 100644 src/HostBridge.ts diff --git a/src/App.tsx b/src/App.tsx index 238815a7..1d51f959 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,12 @@ import { import { AppBar } from "./AppBar"; import { i18n } from "./utils/i18n"; import { useRootElement } from "./RootElementContext"; +import { + createWidgetHostBridge, + HostBridgeProvider, + nullHostBridge, +} from "./HostBridge"; +import { useInitial } from "./useInitial"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -78,13 +84,18 @@ interface Props { } export const App: FC = ({ vm }) => { + // The standalone build has no host; the widget build's host is the client it + // is a widget of. + const hostBridge = useInitial(() => + widget === null ? nullHostBridge : createWidgetHostBridge(widget), + ); const [loaded, setLoaded] = useState(false); useEffect(() => { Initializer.init() ?.then(async () => { if (loaded) return; setLoaded(true); - await widget?.api.sendContentLoaded(); + await hostBridge.contentLoaded(); }) .catch(logger.error); }); @@ -94,7 +105,7 @@ export const App: FC = ({ vm }) => { } + fallback={(error) => } > } /> @@ -112,19 +123,21 @@ export const App: FC = ({ vm }) => { return ( - - - - - - - {content} - - - - - - + + + + + + + + {content} + + + + + + + ); }; diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index 1e32de0e..c5d5e25a 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -9,18 +9,22 @@ import { afterEach, expect, test, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { type MatrixClient } from "matrix-js-sdk"; import { type FC, type PropsWithChildren } from "react"; -import { type WidgetApi } from "matrix-widget-api"; import { ClientContextProvider } from "./ClientContext"; -import { Avatar, getAvatarFromWidgetAPI } from "./Avatar"; +import { Avatar } from "./Avatar"; import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; -import { widget } from "./widget"; +import { + type HostBridge, + HostBridgeProvider, + nullHostBridge, +} from "./HostBridge"; const TestComponent: FC< PropsWithChildren<{ client: MatrixClient; + hostBridge?: HostBridge; }> -> = ({ client, children }) => { +> = ({ client, hostBridge = nullHostBridge, children }) => { return ( - {children} + {children} ); }; -vi.mock("./widget", () => ({ - widget: { - api: null, // Ideally we'd only mock this in the as a widget test so the whole module is otherwise null, but just nulling `api` by default works well enough - }, -})); - afterEach(() => { vi.unstubAllGlobals(); }); @@ -135,7 +133,7 @@ test("should attempt to fetch authenticated media from the server", async () => }); }); -test("should attempt to use widget API if running as a widget", async () => { +test("should download media through the host when it offers to", async () => { const expectedMXCUrl = "mxc://example.org/alice-avatar"; const expectedObjectURL = "my-object-url"; const theBlob = new Blob([]); @@ -151,8 +149,8 @@ test("should attempt to use widget API if running as a widget", async () => { getAccessToken: () => undefined, } as unknown as MatrixClient); - widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi; - vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob }); + const downloadMedia = vi.fn().mockResolvedValue(theBlob); + const hostBridge: HostBridge = { ...nullHostBridge, downloadMedia }; const member = mockMatrixRoomMember( mockRtcMembership("@alice:example.org", "AAAA"), { @@ -161,7 +159,7 @@ test("should attempt to use widget API if running as a widget", async () => { ); const displayName = "Alice"; render( - + { document.querySelector(`img[src='${expectedObjectURL}']`), ); - expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl); -}); - -test("Supports download files as base64", async () => { - const expectedMXCUrl = "mxc://example.org/alice-avatar"; - const expectedBase64 = - "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAADIElEQVR4nAAQA+/8ApxhEfFNuwna" + - "+DO1pFMx5YDg6gb8p1WFkbFSox9H6r5c8jp1gxlHXrDfA/oQFi4A0gTXH9YBNgwRm12xO68QP6lv" + - "ZLKH9qW1VM6kz6zA3T1Ui8J+Xbnh2BZ7oXDe/2gajzoA6j1JGotpz99xO+T2NR634Nhx3zhuera/" + - "UdrpMLdEpwWXLnSqZRasGsrl93FjdTwRBMaqsx6vJksnPOmV9ttbXFIOb0XDGPbVythSC2n7P/bS" + - "Zv0U0QqbBLk/5Wu1werYzAHiz11Bj8bEylQ92Pxvo+PwF6/KbGnIHTvGZkFzDkMnqz3g7Pw3NOSP" + - "oV+qfyJuSI0AeZmrPejFQ8kzBSDWO8D7lr4+6ePRBRmZtKCf+fNjSCOyb5jqwhBnD2cycbJtQQbR" + - "A4qdPG2ONfTPeQgi96+zT7grBI0JwvgFBceJdLJd4BX1VQIyY+j7OYueNWqEpf8iYgMj78I95eRt" + - "nfPLwlxhVns84iL4Yvw8jDrB9vQi8ktpsdJOMiDwKrBGD3q56COD2oIA96CCBgiro4tkvkumZSAc" + - "ZKXRLsziUFGytWJLaPjwnzXv2hicPy6k9AXsF3QkysOZAkB3m9XPpixhq9b0OKqV/zZx3L79o6wZ" + - "Dr40J7sj7f+ARd545CP01r5omHt94tbnjgA46HsM2OhP+qQ882LN+Bhscq2WSHGSHT4J9MQcsWZP" + - "2+N2LdPy61MN4/1++BJHmDcDLQBUEwLvjZp1fRfzxV7yirwIiOA7Vr8z+1yvS/pSkfUzkjswybOd" + - "M5i0I8Q69MTXAKxqtR0/tyGkfCmHfupGASp/SAT9J8f3aQV+gDbpva592v4w8Cv5EMm7CzZPwThF" + - "kgTChNPts7F03ccxpblfIz0EiAON1DKk71rX07BvDlLHY1ItPuqZ7hjy19jrAgl+QqEE1btHVA5R" + - "uAnRXpEWc6rjARlJY5G1wbMk12rrqpr8rhR3YpFgLgOx4BtQ0D/hGe7KANSGBMQojmObId0asCmd" + - "XzmnQI9P8QnwsO9vtqZlgIoU4g+f2/G8Q3/nVMX7dujniwEAAP//KmiQs7P8MeIAAAAASUVORK5C" + - "YII="; - const mockWidgetAPI = { - downloadFile: vi.fn().mockImplementation(async (contentUri) => { - if (contentUri !== expectedMXCUrl) { - return Promise.reject(new Error("Unexpected content URI")); - } - return { file: expectedBase64 }; - }), - } as unknown as WidgetApi; - - const blob = await getAvatarFromWidgetAPI(mockWidgetAPI, expectedMXCUrl); - - expect(blob).toBeInstanceOf(Blob); + expect(downloadMedia).toBeCalledWith(expectedMXCUrl); }); diff --git a/src/Avatar.tsx b/src/Avatar.tsx index 99940540..185ae97b 100644 --- a/src/Avatar.tsx +++ b/src/Avatar.tsx @@ -14,10 +14,9 @@ import { } from "react"; import { Avatar as CompoundAvatar } from "@vector-im/compound-web"; import { type MatrixClient } from "matrix-js-sdk"; -import { type WidgetApi } from "matrix-widget-api"; import { useClientState } from "./ClientContext"; -import { widget } from "./widget"; +import { useHostBridge } from "./HostBridge"; export enum Size { XS = "xs", @@ -76,6 +75,7 @@ export const Avatar: FC = ({ ...props }) => { const clientState = useClientState(); + const hostBridge = useHostBridge(); const sizePx = useMemo( () => @@ -87,7 +87,8 @@ export const Avatar: FC = ({ const [avatarUrl, setAvatarUrl] = useState(undefined); - // In theory, a change in `clientState` or `sizePx` could run extra getAvatarFromWidgetAPI calls, but in practice they should be stable long before this code runs. + // In theory, a change in `clientState` or `sizePx` could run extra media + // downloads, but in practice they should be stable long before this code runs. useEffect(() => { if (!src) { setAvatarUrl(undefined); @@ -96,8 +97,8 @@ export const Avatar: FC = ({ let blob: Promise; - if (widget?.api) { - blob = getAvatarFromWidgetAPI(widget.api, src); + if (hostBridge.downloadMedia) { + blob = hostBridge.downloadMedia(src); } else if ( clientState?.state === "valid" && clientState.authenticated?.client && @@ -132,7 +133,7 @@ export const Avatar: FC = ({ URL.revokeObjectURL(objectUrl); } }; - }, [clientState, src, sizePx]); + }, [clientState, hostBridge, src, sizePx]); return ( { - const response = await api.downloadFile(src); - const file = response.file; - - // element-web sends a Blob, and the MSC4039 is considering changing the spec to strictly Blob, so only handling that - if (file instanceof Blob) { - return file; - } else if (typeof file === "string") { - // it is a base64 string - const bytes = Uint8Array.from(atob(file), (c) => c.charCodeAt(0)); - return new Blob([bytes]); - } - throw new Error( - "Downloaded file format is not supported: " + typeof file + "", - ); -} diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index f2ff3dd4..288665c5 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -339,7 +339,7 @@ export const ClientProvider: FC = ({ children }) => { }, [initClientState, onSync]); if (alreadyOpenedErr) { - return ; + return ; } return {children}; diff --git a/src/ErrorView.tsx b/src/ErrorView.tsx index 1309ae04..51908983 100644 --- a/src/ErrorView.tsx +++ b/src/ErrorView.tsx @@ -21,7 +21,7 @@ import { RageshakeButton } from "./settings/RageshakeButton"; import styles from "./ErrorView.module.css"; import { useUrlParams } from "./UrlParams"; import { LinkButton } from "./button"; -import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts"; +import { useHostBridge } from "./HostBridge.ts"; interface Props { Icon: ComponentType>; @@ -38,7 +38,6 @@ interface Props { */ fatal?: boolean; children: ReactNode; - widget: WidgetHelpers | null; } export const ErrorView: FC = ({ @@ -47,32 +46,27 @@ export const ErrorView: FC = ({ rageshake, fatal, children, - widget, }) => { const { t } = useTranslation(); const { confineToRoom } = useUrlParams(); + const hostBridge = useHostBridge(); const onReload = useCallback(() => { window.location.href = "/"; }, []); - const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({ - widget, + const CloseButton: FC<{ close: () => Promise }> = ({ + close, }): ReactElement => { - // in widget mode we don't want to show the return home button but a close button - const closeWidget = (): void => { - widget.api.transport - .send(ElementWidgetActions.Close, {}) - .catch((e) => { - // What to do here? - logger.error("Failed to send close action", e); - }) - .finally(() => { - widget.api.transport.stop(); - }); + // When the host can dismiss us, offer that instead of a link home + const onClose = (): void => { + close().catch((e) => { + // What to do here? + logger.error("Failed to ask the host to close Element Call", e); + }); }; return ( - ); @@ -108,8 +102,8 @@ export const ErrorView: FC = ({ {rageshake && ( )} - {widget ? ( - + {hostBridge.close ? ( + ) : ( !confineToRoom && )} diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx index eb84010e..ea2a4a0d 100644 --- a/src/FullScreenView.tsx +++ b/src/FullScreenView.tsx @@ -17,7 +17,6 @@ import styles from "./FullScreenView.module.css"; import { useUrlParams } from "./UrlParams"; import { RichError } from "./RichError"; import { ErrorView } from "./ErrorView"; -import { type WidgetHelpers } from "./widget.ts"; interface FullScreenViewProps { className?: string; @@ -48,12 +47,11 @@ export const FullScreenView: FC = ({ interface ErrorPageProps { error: unknown; - widget: WidgetHelpers | null; } // Due to this component being used as the crash fallback for Sentry, which has // weird type requirements, we can't just give this a type of FC -export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => { +export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => { const { t } = useTranslation(); useEffect(() => { logger.error(error); @@ -66,7 +64,6 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => { error.richMessage ) : ( ): WidgetHelpers { + return { + api: api as WidgetApi, + lazyActions: new EventEmitter(), + client: Promise.resolve(), + } as unknown as WidgetHelpers; +} + +describe("createWidgetHostBridge", () => { + describe("downloadMedia", () => { + const mxcUri = "mxc://example.org/alice-avatar"; + + test("passes a Blob through unchanged", async () => { + const file = new Blob([]); + const bridge = createWidgetHostBridge( + mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file }) }), + ); + + await expect(bridge.downloadMedia!(mxcUri)).resolves.toBe(file); + }); + + test("decodes a base64 string into a Blob", async () => { + const bridge = createWidgetHostBridge( + mockWidget({ + // "hello" in base64 + downloadFile: vi.fn().mockResolvedValue({ file: "aGVsbG8=" }), + }), + ); + + const blob = await bridge.downloadMedia!(mxcUri); + + expect(blob).toBeInstanceOf(Blob); + // The five decoded bytes, rather than the eight characters of base64 — + // which is what we'd get if the string were stored verbatim. + expect(blob.size).toBe(5); + }); + + test("rejects a file format it does not understand", async () => { + const bridge = createWidgetHostBridge( + mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file: 42 }) }), + ); + + await expect(bridge.downloadMedia!(mxcUri)).rejects.toThrow( + "Downloaded file format is not supported", + ); + }); + }); + + describe("supportsReactions", () => { + const capabilities = [ + "org.matrix.msc2762.send.event:m.reaction", + "org.matrix.msc2762.send.event:m.room.redaction", + "org.matrix.msc2762.receive.event:m.reaction", + "org.matrix.msc2762.receive.event:m.room.redaction", + ]; + + test("is true when the host grants every reaction capability", () => { + const bridge = createWidgetHostBridge( + mockWidget({ hasCapability: () => true }), + ); + + expect(bridge.supportsReactions).toBe(true); + }); + + test.each(capabilities)("is false without %s", (missing) => { + const bridge = createWidgetHostBridge( + mockWidget({ hasCapability: (c) => c !== missing }), + ); + + expect(bridge.supportsReactions).toBe(false); + }); + }); +}); + +describe("nullHostBridge", () => { + test("offers no way to close, so the interface falls back to navigation", () => { + expect(nullHostBridge.close).toBeUndefined(); + }); + + test("offers no media download, so Element Call uses its own client", () => { + expect(nullHostBridge.downloadMedia).toBeUndefined(); + }); + + test("supports reactions, since nothing is mediating its homeserver access", () => { + expect(nullHostBridge.supportsReactions).toBe(true); + }); +}); diff --git a/src/HostBridge.ts b/src/HostBridge.ts new file mode 100644 index 00000000..f31b5a4a --- /dev/null +++ b/src/HostBridge.ts @@ -0,0 +1,214 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { createContext, use } from "react"; +import { fromEvent, map, NEVER, type Observable } from "rxjs"; +import { + type IWidgetApiRequest, + type IWidgetApiRequestData, + WidgetApiToWidgetAction, +} from "matrix-widget-api"; + +import { + ElementWidgetActions, + type JoinCallData, + type WidgetHelpers, +} from "./widget"; + +// Note: these are type aliases rather than interfaces so that they satisfy the +// widget API's index-signature payload types. + +/** The mute state Element Call and its host exchange. */ +export type DeviceMuteState = { + audio_enabled: boolean; + video_enabled: boolean; +}; + +/** + * A mute state change requested by the host. An absent field means "leave this + * one as it is". + */ +export type DeviceMuteRequest = { + audio_enabled?: boolean; + video_enabled?: boolean; +}; + +/** + * Something the host has asked of Element Call, which it is expected to + * acknowledge. + */ +export interface HostRequest { + data: Data; + /** Acknowledges the request. Should be called exactly once. */ + reply(reply: Reply): void; +} + +/** + * Element Call's view of the application hosting it. + * + * Element Call can run as its own page, as a widget inside a Matrix client, or + * embedded directly into one. Only the last two give it a host, and each of + * them reaches it by a different route — so everything Element Call needs from + * whatever is hosting it goes through this interface, rather than being + * expressed in terms of the widget API. + * + * This covers the interactions Element Call has with its host while running. + * The Matrix client it talks to is supplied separately, at startup. + */ +export interface HostBridge { + // What Element Call tells the host. + + /** + * Asks the host to keep Element Call on screen (or stop doing so), so that a + * call in progress is not torn down when the user navigates elsewhere. + */ + setAlwaysOnScreen(alwaysOnScreen: boolean): Promise; + /** Tells the host that Element Call has finished loading. */ + contentLoaded(): Promise; + /** Tells the host that the user has joined the call. */ + notifyJoined(): Promise; + /** Tells the host that the user has hung up. */ + notifyHungUp(): Promise; + /** Tells the host the user's current audio and video mute state. */ + notifyDeviceMute(state: DeviceMuteState): Promise; + /** + * Asks the host to close Element Call, and stops communicating with it. No + * further calls should be made on this bridge afterwards. + * + * Absent when the host has no way to dismiss Element Call — standalone, the + * user navigates away instead — so its presence is what tells the interface + * whether to offer a close affordance. + */ + close?(): Promise; + + // What the host asks of Element Call. + + /** The host has changed the theme Element Call should use. */ + themeChange$: Observable>; + /** The host wants a preloaded Element Call to join the call now. */ + join$: Observable>; + /** The host wants Element Call to leave the call. */ + hangUp$: Observable>>; + /** The host wants to change, or read back, the device mute state. */ + deviceMute$: Observable>; + + // What the host is capable of. + + /** Whether the host permits Element Call to send and receive reactions. */ + readonly supportsReactions: boolean; + /** + * Fetches media on Element Call's behalf, for hosts that do not give it + * direct access to the homeserver. Absent when Element Call should fetch + * media itself using its own client. + */ + downloadMedia?(mxcUri: string): Promise; +} + +/** + * A bridge to nowhere, for when Element Call has no host — that is, when it is + * running as its own page and talks to the homeserver directly. + */ +export const nullHostBridge: HostBridge = { + setAlwaysOnScreen: async () => {}, + contentLoaded: async () => {}, + notifyJoined: async () => {}, + notifyHungUp: async () => {}, + notifyDeviceMute: async () => {}, + themeChange$: NEVER, + join$: NEVER, + hangUp$: NEVER, + deviceMute$: NEVER, + // Standalone Element Call reaches the homeserver itself, so nothing is + // withholding these from it. + supportsReactions: true, +}; + +/** Bridges to a host that Element Call is a widget of. */ +export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge { + const requests = ( + action: string, + ): Observable> => + ( + fromEvent(widget.lazyActions, action) as Observable< + CustomEvent + > + ).pipe( + map((ev) => ({ + data: ev.detail.data as Data, + // The widget API requires a reply for every request, and carries the + // payload as a plain object, so an empty reply becomes {}. + reply: (reply: Reply): void => + widget.api.transport.reply(ev.detail, reply ?? {}), + })), + ); + + const send = async ( + action: ElementWidgetActions, + data: IWidgetApiRequestData = {}, + ): Promise => { + await widget.api.transport.send(action, data); + }; + + return { + setAlwaysOnScreen: async (alwaysOnScreen) => { + await widget.api.setAlwaysOnScreen(alwaysOnScreen); + }, + contentLoaded: async () => widget.api.sendContentLoaded(), + notifyJoined: async () => send(ElementWidgetActions.JoinCall), + notifyHungUp: async () => send(ElementWidgetActions.HangupCall), + notifyDeviceMute: async (state) => + send(ElementWidgetActions.DeviceMute, state), + close: async () => { + await send(ElementWidgetActions.Close); + widget.api.transport.stop(); + }, + themeChange$: requests(WidgetApiToWidgetAction.ThemeChange), + join$: requests(ElementWidgetActions.JoinCall), + hangUp$: requests(ElementWidgetActions.HangupCall), + deviceMute$: requests(ElementWidgetActions.DeviceMute), + // Element Call needs the host's permission to send reactions on its behalf. + // Read on access rather than up front: the widget API negotiates its + // capabilities asynchronously, and the bridge is built before that settles. + get supportsReactions(): boolean { + return ( + widget.api.hasCapability("org.matrix.msc2762.send.event:m.reaction") && + widget.api.hasCapability( + "org.matrix.msc2762.send.event:m.room.redaction", + ) && + widget.api.hasCapability( + "org.matrix.msc2762.receive.event:m.reaction", + ) && + widget.api.hasCapability( + "org.matrix.msc2762.receive.event:m.room.redaction", + ) + ); + }, + downloadMedia: async (mxcUri) => { + const { file } = await widget.api.downloadFile(mxcUri); + if (file instanceof Blob) return file; + if (typeof file === "string") + // it is a base64 string + return new Blob([Uint8Array.from(atob(file), (c) => c.charCodeAt(0))]); + throw new Error( + `Downloaded file format is not supported: ${typeof file}`, + ); + }, + }; +} + +const HostBridgeContext = createContext(null); + +export const HostBridgeProvider = HostBridgeContext.Provider; + +/** + * The application hosting Element Call. + * + * Defaults to {@link nullHostBridge}, so that tests and stories, which have no + * host, need no provider. + */ +export const useHostBridge = (): HostBridge => + use(HostBridgeContext) ?? nullHostBridge; diff --git a/src/RichError.tsx b/src/RichError.tsx index 699486e2..abacf0b3 100644 --- a/src/RichError.tsx +++ b/src/RichError.tsx @@ -10,7 +10,6 @@ import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import type { FC, ReactNode } from "react"; import { ErrorView } from "./ErrorView"; -import { widget } from "./widget.ts"; /** * An error consisting of a terse message to be logged to the console and a @@ -32,11 +31,7 @@ const OpenElsewhere: FC = () => { const { t } = useTranslation(); return ( - +

{t("error.open_elsewhere_description", { brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call", diff --git a/src/home/HomePage.tsx b/src/home/HomePage.tsx index ca1f0ea8..e6136855 100644 --- a/src/home/HomePage.tsx +++ b/src/home/HomePage.tsx @@ -13,7 +13,6 @@ import { ErrorPage, LoadingPage } from "../FullScreenView"; import { UnauthenticatedView } from "./UnauthenticatedView"; import { RegisteredView } from "./RegisteredView"; import { usePageTitle } from "../usePageTitle"; -import { widget } from "../widget.ts"; export const HomePage: FC = () => { const { t } = useTranslation(); @@ -24,7 +23,7 @@ export const HomePage: FC = () => { if (!clientState) { return ; } else if (clientState.state === "error") { - return ; + return ; } else { return clientState.authenticated ? ( diff --git a/src/room/GroupCallErrorBoundary.test.tsx b/src/room/GroupCallErrorBoundary.test.tsx index e10044ae..a70b3180 100644 --- a/src/room/GroupCallErrorBoundary.test.tsx +++ b/src/room/GroupCallErrorBoundary.test.tsx @@ -36,7 +36,11 @@ import { UnknownCallError, } from "../utils/errors.ts"; import { mockConfig } from "../utils/test.ts"; -import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts"; +import { + type HostBridge, + HostBridgeProvider, + nullHostBridge, +} from "../HostBridge.ts"; test.each([ { @@ -79,7 +83,6 @@ test.each([ @@ -108,7 +111,6 @@ test("should render the error page with link back to home", async () => { @@ -154,10 +156,7 @@ test("ConnectionLostError: Action handling should reset error state", async () = return ( - + @@ -199,7 +198,6 @@ describe("Rageshake button", () => { @@ -224,29 +222,27 @@ describe("Rageshake button", () => { }); }); -test("should have a close button in widget mode", async () => { +test("should have a close button when the host can dismiss us", async () => { const error = new MatrixRTCTransportMissingError("example.com"); const TestComponent = (): ReactNode => { throw error; }; - const mockWidget = { - api: { - transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() }, - }, - } as unknown as WidgetHelpers; + const close = vi.fn().mockResolvedValue(undefined); + const hostBridge: HostBridge = { ...nullHostBridge, close }; const user = userEvent.setup(); const onErrorMock = vi.fn(); const { asFragment } = render( - - - + + + + + , ); @@ -258,11 +254,7 @@ test("should have a close button in widget mode", async () => { await user.click(screen.getByRole("button", { name: "Close" })); - expect(mockWidget.api.transport.send).toHaveBeenCalledWith( - ElementWidgetActions.Close, - expect.anything(), - ); - expect(mockWidget.api.transport.stop).toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); }); test("should show technical details when error has a matrixError cause", async () => { @@ -282,11 +274,7 @@ test("should show technical details when error has a matrixError cause", async ( render( - + , @@ -315,11 +303,7 @@ test("should not show technical details when error has no matrix error cause", a render( - + , @@ -376,7 +360,6 @@ describe("LiveKit ConnectionError variants", () => { @@ -406,7 +389,6 @@ describe("LiveKit ConnectionError variants", () => { diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx index 390a5a8c..6d43cf78 100644 --- a/src/room/GroupCallErrorBoundary.tsx +++ b/src/room/GroupCallErrorBoundary.tsx @@ -34,7 +34,6 @@ import { } from "../utils/errors.ts"; import { FullScreenView } from "../FullScreenView.tsx"; import { ErrorView } from "../ErrorView.tsx"; -import { type WidgetHelpers } from "../widget.ts"; import styles from "../ErrorView.module.css"; export type CallErrorRecoveryAction = "reconnect"; // | "retry" ; @@ -47,13 +46,11 @@ interface ErrorPageProps { error: ElementCallError; recoveryActionHandler: RecoveryActionHandler; resetError: () => void; - widget: WidgetHelpers | null; } const ErrorPage: FC = ({ error, recoveryActionHandler, - widget, }: ErrorPageProps): ReactElement => { const { t } = useTranslation(); logger.error("Error boundary caught:", error); @@ -89,7 +86,6 @@ const ErrorPage: FC = ({ Icon={icon} title={error.localisedTitle} rageshake={error.code == ErrorCode.UNKNOWN_ERROR} - widget={widget} >

{error.localisedMessageKey ? ( @@ -148,14 +144,12 @@ interface BoundaryProps { children: ReactNode | (() => ReactNode); recoveryActionHandler: RecoveryActionHandler; onError?: (error: unknown) => void; - widget: WidgetHelpers | null; } export const GroupCallErrorBoundary = ({ recoveryActionHandler, onError, children, - widget, }: BoundaryProps): ReactElement => { const fallbackRenderer: FallbackRender = useCallback( ({ error, resetError }): ReactElement => { @@ -165,7 +159,6 @@ export const GroupCallErrorBoundary = ({ : new UnknownCallError(error instanceof Error ? error : new Error()); return ( { @@ -175,7 +168,7 @@ export const GroupCallErrorBoundary = ({ /> ); }, - [recoveryActionHandler, widget], + [recoveryActionHandler], ); return ( diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index a5c3b0d8..a53288c2 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -36,7 +36,6 @@ import userEvent, { import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; import { useState } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; -import { type ITransport } from "matrix-widget-api"; import { prefetchSounds } from "../soundUtils"; import { useAudioContext } from "../useAudioContext"; @@ -52,8 +51,11 @@ import { } from "../utils/test"; import { GroupCallView } from "./GroupCallView"; import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary"; -import { ElementWidgetActions, type WidgetHelpers } from "../widget"; -import { LazyEventEmitter } from "../LazyEventEmitter"; +import { + type HostBridge, + HostBridgeProvider, + nullHostBridge, +} from "../HostBridge"; import { MatrixRTCTransportMissingError } from "../utils/errors"; import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { MediaDevicesContext } from "../MediaDevicesContext"; @@ -134,7 +136,7 @@ beforeEach(() => { }); function createGroupCallView( - widget: WidgetHelpers | null, + hostBridge: HostBridge, joined = true, options: { withErrorBoundary?: boolean; @@ -184,7 +186,6 @@ function createGroupCallView( skipLobby={false} rtcSession={rtcSession.asMockedSession()} muteStates={muteState} - widget={widget} // TODO-MULTI-SFU: Make joined and setJoined work joined={true} setJoined={function (value: boolean): void {}} @@ -192,22 +193,21 @@ function createGroupCallView( ); const { getByText } = render( - - - - {options.withErrorBoundary ? ( - - {groupCallView} - - ) : ( - groupCallView - )} - - - + + + + + {options.withErrorBoundary ? ( + + {groupCallView} + + ) : ( + groupCallView + )} + + + + , ); return { @@ -218,7 +218,7 @@ function createGroupCallView( test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => { const user = userEvent.setup(); - const { getByText, rtcSession } = createGroupCallView(null); + const { getByText, rtcSession } = createGroupCallView(nullHostBridge); const leaveButton = getByText("Leave"); await user.click(leaveButton); expect(playSound).toHaveBeenCalledWith("left"); @@ -235,12 +235,7 @@ test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async test.skip("GroupCallView plays a leave sound synchronously in widget mode", async () => { const user = userEvent.setup(); - const widget = { - api: { - setAlwaysOnScreen: async () => Promise.resolve(true), - } as Partial, - lazyActions: new LazyEventEmitter(), - }; + const hostBridge: HostBridge = { ...nullHostBridge, close: vi.fn() }; let resolvePlaySound: () => void; playSound = vi .fn() @@ -253,9 +248,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn soundDuration: {}, }); - const { getByText, rtcSession } = createGroupCallView( - widget as WidgetHelpers, - ); + const { getByText, rtcSession } = createGroupCallView(hostBridge); const leaveButton = getByText("Leave"); await user.click(leaveButton); await flushPromises(); @@ -272,28 +265,13 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn expect(leaveRTCSession).toHaveBeenCalledOnce(); }); -test("Should close widget when all other left and play a sound", async () => { +test("Should ask the host to close when all other left and play a sound", async () => { const user = userEvent.setup(); - let widgetClosedCalled = false; - const { promise: widgetClosedPromise, resolve: widgetClosedResolver } = - Promise.withResolvers(); - const widgetSendMock = vi.fn().mockImplementation((action: string) => { - if (action === ElementWidgetActions.Close) { - widgetClosedCalled = true; - widgetClosedResolver(); - } - }); - const widgetStopMock = vi.fn().mockResolvedValue(undefined); - const widget = { - api: { - setAlwaysOnScreen: vi.fn().mockResolvedValue(true), - transport: { - send: widgetSendMock, - reply: vi.fn().mockResolvedValue(undefined), - stop: widgetStopMock, - } as unknown as ITransport, - } as Partial, - lazyActions: new LazyEventEmitter(), + const close = vi.fn().mockResolvedValue(undefined); + const hostBridge: HostBridge = { + ...nullHostBridge, + setAlwaysOnScreen: vi.fn().mockResolvedValue(undefined), + close, }; const resolvePlaySound = Promise.withResolvers(); playSound = vi.fn().mockReturnValue(resolvePlaySound.promise); @@ -303,50 +281,38 @@ test("Should close widget when all other left and play a sound", async () => { soundDuration: {}, }); - const { getByText } = createGroupCallView(widget as WidgetHelpers); + const { getByText } = createGroupCallView(hostBridge); const leaveButton = getByText("SimulateOtherLeft"); await user.click(leaveButton); await flushPromises(); - expect(widgetClosedCalled).toBeFalsy(); + expect(close).not.toHaveBeenCalled(); resolvePlaySound.resolve(); expect(playSound).toHaveBeenCalledWith("left", 0); - await widgetClosedPromise; - await flushPromises(); - expect(widgetClosedCalled).toBeTruthy(); - expect(widgetStopMock).toHaveBeenCalledOnce(); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); }, 80000); -test("Should not close widget when auto leave due to error", async () => { +test("Should not ask the host to close when auto leave due to error", async () => { const user = userEvent.setup(); - const widgetStopMock = vi.fn().mockResolvedValue(undefined); - const widgetSendMock = vi.fn().mockResolvedValue(undefined); - const widget = { - api: { - setAlwaysOnScreen: vi.fn().mockResolvedValue(true), - transport: { - send: widgetSendMock, - reply: vi.fn().mockResolvedValue(undefined), - stop: widgetStopMock, - } as unknown as ITransport, - } as Partial, - lazyActions: new LazyEventEmitter(), + const close = vi.fn().mockResolvedValue(undefined); + const setAlwaysOnScreen = vi.fn().mockResolvedValue(undefined); + const hostBridge: HostBridge = { + ...nullHostBridge, + setAlwaysOnScreen, + close, }; - const alwaysOnScreenSpy = vi.spyOn(widget.api, "setAlwaysOnScreen"); - - const { getByText } = createGroupCallView(widget as WidgetHelpers); + const { getByText } = createGroupCallView(hostBridge); const leaveButton = getByText("SimulateErrorLeft"); await user.click(leaveButton); await flushPromises(); // When onLeft is called, we first set always on screen to false - await waitFor(() => expect(alwaysOnScreenSpy).toHaveBeenCalledWith(false)); + await waitFor(() => expect(setAlwaysOnScreen).toHaveBeenCalledWith(false)); await flushPromises(); - // But then we do not close the widget automatically - expect(widgetStopMock).not.toHaveBeenCalledOnce(); - expect(widgetSendMock).not.toHaveBeenCalledOnce(); + // But then we do not ask to be closed automatically + expect(close).not.toHaveBeenCalled(); }); test.skip("GroupCallView leaves the session when an error occurs", async () => { @@ -360,7 +326,7 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => { ); }); const user = userEvent.setup(); - const { rtcSession } = createGroupCallView(null); + const { rtcSession } = createGroupCallView(nullHostBridge); await user.click(screen.getByRole("button", { name: "Panic!" })); screen.getByText("Something went wrong"); expect(leaveRTCSession).toHaveBeenCalledWith( @@ -377,7 +343,7 @@ test.skip("GroupCallView shows errors that occur during joining", async () => { onTestFinished(() => { enterRTCSession.mockReset(); }); - createGroupCallView(null, false); + createGroupCallView(nullHostBridge, false); await user.click(screen.getByRole("button", { name: "Join call" })); screen.getByText("Call is not supported"); }); @@ -396,7 +362,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent { cause: stickyError }, ); - const { rtcSession } = createGroupCallView(null, true, { + const { rtcSession } = createGroupCallView(nullHostBridge, true, { withErrorBoundary: true, }); @@ -408,7 +374,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent }); test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => { - const { rtcSession } = createGroupCallView(null, true, { + const { rtcSession } = createGroupCallView(nullHostBridge, true, { withErrorBoundary: true, }); @@ -424,7 +390,7 @@ test("falls back to ConnectionLostError for unrecognised membership manager erro test("user can reconnect after a membership manager error", async () => { const user = userEvent.setup(); - const { rtcSession } = createGroupCallView(null, true); + const { rtcSession } = createGroupCallView(nullHostBridge, true); await act(() => rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined), ); diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 57e9205f..623b8839 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -30,12 +30,7 @@ import { } from "matrix-js-sdk/lib/matrixrtc"; import { useNavigate } from "react-router-dom"; -import type { IWidgetApiRequest } from "matrix-widget-api"; -import { - ElementWidgetActions, - type JoinCallData, - type WidgetHelpers, -} from "../widget"; +import { type JoinCallData } from "../widget"; import { LobbyView } from "./LobbyView"; import { type MatrixInfo } from "./VideoPreview"; import { CallEndedView } from "./CallEndedView"; @@ -76,6 +71,7 @@ import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useAppBarTitle } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; import { useRootElement } from "../RootElementContext.ts"; +import { useHostBridge } from "../HostBridge.ts"; /** * If there already are this many participants in the call, we automatically mute @@ -99,7 +95,6 @@ interface Props { joined: boolean; setJoined: (value: boolean) => void; muteStates: MuteStates; - widget: WidgetHelpers | null; } export const GroupCallView: FC = ({ @@ -112,7 +107,6 @@ export const GroupCallView: FC = ({ joined, setJoined, muteStates, - widget, }) => { // Used to thread through any errors that occur outside the error boundary const [externalError, setExternalError] = useState( @@ -120,6 +114,14 @@ export const GroupCallView: FC = ({ ); const memberships = useMatrixRTCSessionMemberships(rtcSession); const rootElement = useRootElement(); + const hostBridge = useHostBridge(); + // A host that can close us is a host that decides when we stop existing, so + // we neither show our own post-call screens nor assume we have time to + // finish what we are doing. + // TODO: this reads a capability as a proxy for who owns our lifetime. Worth + // finding a more direct way to express it — see the guidance in UrlParams.ts + // on naming behaviours rather than situations. + const hostControlsLifetime = hostBridge.close !== undefined; const muteAllAudio = useBehavior(muteAllAudio$); const leaveSoundContext = useLatest( @@ -294,28 +296,26 @@ export const GroupCallView: FC = ({ }; if (skipLobby) { - if (widget && preload) { + // `preload` is only ever set when we have a host to be preloaded by. + if (preload) { // In preload mode without lobby we wait for a join action before entering - const onJoin = (ev: CustomEvent): void => { + const subscription = hostBridge.join$.subscribe(({ data, reply }) => { (async (): Promise => { - await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData); + await defaultDeviceSetup(data); setJoined(true); - widget.api.transport.reply(ev.detail, {}); + reply(); })().catch((e) => { logger.error("Error joining RTC session on preload", e); }); - }; - widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin); - return (): void => { - widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin); - }; + }); + return (): void => subscription.unsubscribe(); } else { // No lobby and no preload: we enter the rtc session right away setJoined(true); } } }, [ - widget, + hostBridge, rtcSession, preload, skipLobby, @@ -341,7 +341,7 @@ export const GroupCallView: FC = ({ // When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer // already plays the "left" sound when the remote participant's media // disappears. We play it here silenced (volumeOverwrite = 0) so we have the right duration in the audioPromise. - // (used to destory the widget) + // (which is what delays asking the host to close us) audioPromise = leaveSoundContext.current?.playSound("left", 0); break; case "timeout": @@ -356,12 +356,12 @@ export const GroupCallView: FC = ({ setLeft(true); // We need to wait until the callEnded event is tracked on PostHog, - // otherwise the iframe may get killed first. + // otherwise we may be torn down first. const posthogRequest = new Promise((resolve) => { - // To increase the likelihood of the PostHog event being sent out in - // widget mode before the iframe is killed, we ask it to skip the - // usual queuing/batching of requests. - const sendInstantly = widget !== null; + // To increase the likelihood of the PostHog event being sent out + // before the host disposes of us, we ask it to skip the usual + // queuing/batching of requests. + const sendInstantly = hostControlsLifetime; PosthogAnalytics.instance.eventCallEnded.track( room.roomId, rtcSession.memberships.length, @@ -369,8 +369,8 @@ export const GroupCallView: FC = ({ rtcSession, ); // Unfortunately the PostHog library provides no way to await the - // tracking of an event, but we don't really want it to hold up the - // closing of the widget that long anyway, so giving it 10 ms will do. + // tracking of an event, but we don't really want it to hold up our + // disposal that long anyway, so giving it 10 ms will do. window.setTimeout(resolve, 10); }); @@ -389,25 +389,19 @@ export const GroupCallView: FC = ({ ) void navigate("/"); - if (widget) { - // After this point the iframe could die at any moment! + // After this point the host could dispose of us at any moment! + try { + await hostBridge.setAlwaysOnScreen(false); + } catch (e) { + logger.error("Failed to set `alwaysOnScreen` to false", e); + } + // On a normal user hangup we can shut down and ask to be closed. But + // if an error occurs we should stay open until the user reads it. + if (reason != "error" && !returnToLobby) { try { - await widget.api.setAlwaysOnScreen(false); + await hostBridge.close?.(); } catch (e) { - logger.error( - "Failed to set call widget `alwaysOnScreen` to false", - e, - ); - } - // On a normal user hangup we can shut down and close the widget. But if an - // error occurs we should keep the widget open until the user reads it. - if (reason != "error" && !returnToLobby) { - try { - await widget.api.transport.send(ElementWidgetActions.Close, {}); - } catch (e) { - logger.error("Failed to send close action", e); - } - widget.api.transport.stop(); + logger.error("Failed to ask the host to close Element Call", e); } } }); @@ -415,7 +409,8 @@ export const GroupCallView: FC = ({ [ setJoined, leaveSoundContext, - widget, + hostBridge, + hostControlsLifetime, room.roomId, rtcSession, isPasswordlessUser, @@ -426,12 +421,12 @@ export const GroupCallView: FC = ({ ); useEffect(() => { - if (widget && joined) - // set widget to sticky once joined. - widget.api.setAlwaysOnScreen(true).catch((e) => { + if (joined) + // ask to be kept on screen once joined. + hostBridge.setAlwaysOnScreen(true).catch((e) => { logger.error("Error calling setAlwaysOnScreen(true)", e); }); - }, [widget, joined, rtcSession]); + }, [hostBridge, joined, rtcSession]); const joinRule = useJoinRule(room); @@ -501,19 +496,16 @@ export const GroupCallView: FC = ({ /> ); - } else if (left && widget === null) { - // Left in SPA mode: + } else if (left && !hostControlsLifetime) { + // Left, and it is up to us what to show next: // The call ended view is shown for two reasons: prompting guests to create // an account, and prompting users that have opted into analytics to provide - // feedback. We don't show a feedback prompt to widget users however (at - // least for now), because we don't yet have designs that would allow widget - // users to dismiss the feedback prompt and close the call window without - // submitting anything. - if ( - isPasswordlessUser || - (PosthogAnalytics.instance.isEnabled() && widget === null) - ) { + // feedback. We don't show a feedback prompt when a host owns our lifetime + // however (at least for now), because we don't yet have designs that would + // allow those users to dismiss the feedback prompt and close the call + // window without submitting anything. + if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) { body = ( = ({ // LobbyView again which would open capture devices again. body = null; } - } else if (left && widget !== null) { - // Left in widget mode: + } else if (left && hostControlsLifetime) { + // Left, and the host decides what happens next: body = returnToLobby ? lobbyView : null; } else if (preload || skipLobby) { // The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called. @@ -541,7 +533,6 @@ export const GroupCallView: FC = ({ return ( { setExternalError(null); if (action == "reconnect") { @@ -553,9 +544,10 @@ export const GroupCallView: FC = ({ }} onError={(_error) => { if (rtcSession.isJoined()) onLeft("error"); - // If there is an error we need to be able to close the widget. This is done in `onLeft` as well - // We need it here explicitly in case rtcSession.isJoined is false. - void widget?.api.setAlwaysOnScreen(false); + // If there is an error we need to be dismissible again. This is done in + // `onLeft` as well; we need it here explicitly in case + // rtcSession.isJoined is false. + void hostBridge.setAlwaysOnScreen(false); }} > {body} diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index b1ffb3ba..b5f943ce 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -123,7 +123,6 @@ export const RoomPage: FC = (): ReactNode => { return ( muteStates && ( {

@@ -216,7 +214,6 @@ export const RoomPage: FC = (): ReactNode => {

{groupCallState.error.messageBody}

{groupCallState.error.reason && ( @@ -230,7 +227,7 @@ export const RoomPage: FC = (): ReactNode => { ); } else { - return ; + return ; } default: return <> ; @@ -238,7 +235,7 @@ export const RoomPage: FC = (): ReactNode => { }; if (loading || isRegistering) return ; - if (error) return ; + if (error) return ; if (!client) return ; // TODO: This doesn't belong here, the app routes need to be reworked if (!roomIdOrAlias) return ; diff --git a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap index 4239cee1..0194dfc4 100644 --- a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap +++ b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap @@ -1100,7 +1100,7 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh `; -exports[`should have a close button in widget mode 1`] = ` +exports[`should have a close button when the host can dismiss us 1`] = `
({ useUrlParams: vi.fn() })); -vi.mock("./widget", () => ({ - widget: { - api: { transport: { reply: vi.fn() } }, - lazyActions: new EventEmitter(), - }, -})); describe("useTheme", () => { let originalClassList: DOMTokenList; + let themeChange$: Subject>; + let wrapper: FC; + beforeEach(() => { + themeChange$ = new Subject(); + const hostBridge: HostBridge = { ...nullHostBridge, themeChange$ }; + wrapper = ({ children }) => + createElement(HostBridgeProvider, { value: hostBridge }, children); // Save the original classList to setup spies originalClassList = document.body.classList; @@ -55,7 +61,7 @@ describe("useTheme", () => { test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => { (useUrlParams as Mock).mockReturnValue({ theme: setTheme }); - renderHook(() => useTheme()); + renderHook(() => useTheme(), { wrapper }); expect(originalClassList.remove).toHaveBeenCalledWith( "cpd-theme-light", @@ -71,7 +77,7 @@ describe("useTheme", () => { // Simulate a previous theme originalClassList.item = vi.fn().mockReturnValue("cpd-theme-dark"); - renderHook(() => useTheme()); + renderHook(() => useTheme(), { wrapper }); expect(document.body.classList.add).not.toHaveBeenCalledWith( "cpd-theme-dark", @@ -82,18 +88,13 @@ describe("useTheme", () => { expect(originalClassList.add).not.toHaveBeenCalled(); }); - test("theme changes in response to widget actions", async () => { - renderHook(() => useTheme()); + test("theme changes in response to host requests", () => { + renderHook(() => useTheme(), { wrapper }); expect(originalClassList.add).toHaveBeenCalledWith("cpd-theme-dark"); - await act(() => - widget!.lazyActions.emit( - WidgetApiToWidgetAction.ThemeChange, - new CustomEvent(WidgetApiToWidgetAction.ThemeChange, { - detail: { data: { name: "light" } }, - }), - ), - ); + const reply = vi.fn(); + act(() => themeChange$.next({ data: { name: "light" }, reply })); + expect(reply).toHaveBeenCalledOnce(); expect(originalClassList.remove).toHaveBeenCalledWith( "cpd-theme-light", "cpd-theme-dark", diff --git a/src/useTheme.ts b/src/useTheme.ts index 85dace2a..3c02745a 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -6,39 +6,27 @@ Please see LICENSE in the repository root for full details. */ import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { WidgetApiToWidgetAction } from "matrix-widget-api"; -import { type IThemeChangeActionRequest } from "matrix-widget-api"; import { useUrlParams } from "./UrlParams"; -import { widget } from "./widget"; import { useRootElement } from "./RootElementContext"; +import { useHostBridge } from "./HostBridge"; export const useTheme = (): void => { const rootElement = useRootElement(); + const hostBridge = useHostBridge(); const { theme } = useUrlParams(); const [requestedTheme, setRequestedTheme] = useState(theme); const previousTheme = useRef(rootElement.classList.item(0)); useEffect(() => { - if (widget) { - const onThemeChange = ( - ev: CustomEvent, - ): void => { - ev.preventDefault(); - if ("name" in ev.detail.data && typeof ev.detail.data.name === "string") - setRequestedTheme(ev.detail.data.name); - widget!.api.transport.reply(ev.detail, {}); - }; - - widget.lazyActions.on(WidgetApiToWidgetAction.ThemeChange, onThemeChange); - return (): void => { - widget!.lazyActions.off( - WidgetApiToWidgetAction.ThemeChange, - onThemeChange, - ); - }; - } - }, []); + const subscription = hostBridge.themeChange$.subscribe( + ({ data, reply }) => { + if (typeof data.name === "string") setRequestedTheme(data.name); + reply(); + }, + ); + return (): void => subscription.unsubscribe(); + }, [hostBridge]); useLayoutEffect(() => { // If no theme has been explicitly requested we default to dark From bc6c232ef092d721500693e95f8793945d13c5dd Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 13:23:01 +0200 Subject: [PATCH 013/154] Move the state layer onto the host bridge MuteStates, CallViewModel and LocalMember reached the host through the widget global. None of them are React components, so they take the bridge as an explicit parameter: a constructor argument for MuteStates, a field on CallViewModelOptions, and one on createLocalMembership$'s props. src/state no longer refers to the widget API. The conditionals around it mostly disappear: nullHostBridge's observables are NEVER, so there is nothing to guard, and a request carries its own reply rather than needing the transport and the original event. CallViewModelWidget.test.ts drove hangup by emitting on the mocked widget's action emitter, so it now injects a bridge instead, and checks that the request is acknowledged. --- sdk/main.ts | 13 ++- src/room/InCallView.tsx | 4 + src/room/RoomPage.tsx | 5 +- src/state/CallViewModel/CallViewModel.ts | 30 +++--- .../localMember/LocalMember.test.ts | 2 + .../CallViewModel/localMember/LocalMember.ts | 27 +++-- src/state/CallViewModelWidget.test.ts | 46 +++------ src/state/MuteStates.test.ts | 11 ++- src/state/MuteStates.ts | 99 +++++++++---------- src/utils/test.ts | 11 ++- 10 files changed, 120 insertions(+), 128 deletions(-) diff --git a/sdk/main.ts b/sdk/main.ts index 55aa4a02..de15a575 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -60,6 +60,7 @@ import { initializeWidget, } from "../src/widget"; import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection"; +import { createWidgetHostBridge } from "../src/HostBridge"; interface MatrixRTCSdk { /** @@ -113,6 +114,7 @@ export async function createMatrixRTCSdk( const widget = _widget; if (!widget) throw Error("No widget. This webapp can only start as a widget"); const client = await widget.client; + const hostBridge = createWidgetHostBridge(widget); logger.info("client created"); // url params @@ -132,10 +134,12 @@ export async function createMatrixRTCSdk( controlledAudioDevices, callIntent, }); - const muteStates = new MuteStates(scope, mediaDevices, { - audioEnabled: false, - videoEnabled: false, - }); + const muteStates = new MuteStates( + scope, + mediaDevices, + { audioEnabled: false, videoEnabled: false }, + hostBridge, + ); // call view model const callViewModel = createCallViewModel$( @@ -146,6 +150,7 @@ export async function createMatrixRTCSdk( muteStates, { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + hostBridge, controlledAudioDevices, callIntent, }, diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e291e001..e2edde84 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -29,6 +29,7 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { widget } from "../widget"; +import { useHostBridge } from "../HostBridge.ts"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; @@ -116,6 +117,7 @@ export const ActiveCall: FC = (props) => { useState | null>(null); const urlParams = useUrlParams(); + const hostBridge = useHostBridge(); const mediaDevices = useMediaDevices(); const trackProcessorState$ = useTrackProcessorObservable$(); useEffect(() => { @@ -141,6 +143,7 @@ export const ActiveCall: FC = (props) => { props.muteStates, { encryptionSystem: props.e2eeSystem, + hostBridge, autoLeaveWhenOthersLeft, waitForCallPickup: waitForCallPickup && sendNotificationType === "ring", matrixRTCMode$: matrixRTCModeSetting.value$, @@ -171,6 +174,7 @@ export const ActiveCall: FC = (props) => { props.e2eeSystem, props.onLeft, urlParams, + hostBridge, mediaDevices, trackProcessorState$, props.client, diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index b5f943ce..00905ac4 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -30,6 +30,7 @@ import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { HomePage } from "../home/HomePage"; import { widget } from "../widget"; +import { useHostBridge } from "../HostBridge.ts"; import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall"; import { LobbyView } from "./LobbyView"; import { E2eeType } from "../e2ee/e2eeType"; @@ -44,6 +45,7 @@ import { calculateInitialMuteState } from "../state/initialMuteState.ts"; export const RoomPage: FC = (): ReactNode => { const urlParams = useUrlParams(); + const hostBridge = useHostBridge(); const { confineToRoom, preload, header, displayName, skipLobby } = urlParams; const { t } = useTranslation(); const { roomAlias, roomId, viaServers } = useRoomIdentifier(); @@ -77,10 +79,11 @@ export const RoomPage: FC = (): ReactNode => { urlParams.callIntent, widget !== null, ), + hostBridge, ), ); return (): void => scope.end(); - }, [devices, urlParams]); + }, [devices, urlParams, hostBridge]); useEffect(() => { // If we've finished loading, are not already authed and we've been given a display name as diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index e9abeae5..b40a4523 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -48,7 +48,6 @@ import { type RTCCallIntent, type RTCNotificationType, } from "matrix-js-sdk/lib/matrixrtc"; -import { type IWidgetApiRequest } from "matrix-widget-api"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; import { v4 as uuidv4 } from "uuid"; import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager"; @@ -89,7 +88,7 @@ import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider"; import { type MuteStates } from "../MuteStates"; import { HeaderStyle } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; -import { ElementWidgetActions, widget } from "../../widget"; +import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { layoutShallowEquals, type Alignment, @@ -173,6 +172,11 @@ import { type GridTileViewModel } from "../TileViewModel.ts"; // callMembership -> rtcMembership export interface CallViewModelOptions { encryptionSystem: EncryptionSystem; + /** + * The application hosting Element Call, which can ask it to hang up and wants + * to know when the user joins or leaves. Defaults to no host. + */ + hostBridge?: HostBridge; /** * Whether the app hosting Element Call controls the audio output devices, * rather than the browser. Defaults to false. @@ -457,6 +461,7 @@ export function createCallViewModel$( // Defaults match what the URL parameters resolve to outside of widget mode, // so that callers which don't care (chiefly tests) behave as they always have. const { + hostBridge = nullHostBridge, controlledAudioDevices = false, header = HeaderStyle.Standard, showControls = true, @@ -632,6 +637,7 @@ export function createCallViewModel$( localTransport$, roomId: matrixRoom.roomId, hideScreensharing, + hostBridge, logger: logger.getChild(`[${Date.now()}]`), }); @@ -923,24 +929,16 @@ export function createCallViewModel$( const userHangup$ = new Subject(); - const widgetHangup$ = - widget === null - ? NEVER - : ( - fromEvent( - widget.lazyActions, - ElementWidgetActions.HangupCall, - ) as Observable> - ).pipe( - tap((ev) => { - widget!.api.transport.reply(ev.detail, {}); - }), - ); + const hostHangup$ = hostBridge.hangUp$.pipe( + tap((request) => { + request.reply(); + }), + ); const leave$: Observable<"user" | "timeout" | "decline" | "allOthersLeft"> = merge( autoLeave$, - merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)), + merge(userHangup$, hostHangup$).pipe(map(() => "user" as const)), ).pipe(scope.share); const spotlightSpeaker$ = scope.behavior( diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 2b89d50b..4826dcdd 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -52,6 +52,7 @@ import { ConnectionManagerData } from "../remoteMembers/ConnectionManager"; import { ConnectionState, type Connection } from "../remoteMembers/Connection"; import { type Publisher } from "./Publisher"; import { initializeWidget } from "../../../widget"; +import { nullHostBridge } from "../../../HostBridge"; import { type LocalTransport, type LocalTransportWithSFUConfig, @@ -200,6 +201,7 @@ describe("LocalMembership", () => { }, roomId: "!test-room-id:example.org", hideScreensharing: false, + hostBridge: nullHostBridge, }; it("throws error on missing RTC config error", () => { diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 844b66de..e28f5fe5 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -53,7 +53,7 @@ import { MembershipManagerError, UnknownCallError, } from "../../../utils/errors.ts"; -import { ElementWidgetActions, widget } from "../../../widget.ts"; +import { type HostBridge } from "../../../HostBridge.ts"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; import { @@ -145,6 +145,8 @@ interface Props { >; /** Whether to hide the screen-sharing button. */ hideScreensharing: boolean; + /** The application hosting Element Call, to be kept informed of join/leave. */ + hostBridge: HostBridge; logger: Logger; } @@ -165,6 +167,7 @@ interface Props { * @param props.matrixRTCSession The matrix RTC session to join. * @param props.roomId The room ID used as the call identifier in analytics events. * @param props.hideScreensharing Whether to hide the screen-sharing button. + * @param props.hostBridge The application hosting Element Call. * @returns * - publisher: The handle to create tracks and publish them to the room. * - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication) @@ -184,6 +187,7 @@ export const createLocalMembership$ = ({ matrixRTCSession, roomId, hideScreensharing, + hostBridge, }: Props): { /** * This request to start audio and video tracks. @@ -576,29 +580,24 @@ export const createLocalMembership$ = ({ } }); - // inform the widget about the connect and disconnect intent from the user. + // inform the host about the connect and disconnect intent from the user. scope .behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [ undefined, joinAndPublishRequested$.value, ]) .subscribe(([prev, current]) => { - if (!widget) return; // JOIN prev=false (was left) => current-true (now joiend) if (!prev && current) { - widget.api.transport - .send(ElementWidgetActions.JoinCall, {}) - .catch((e) => { - logger.error("Failed to send join action", e); - }); + hostBridge.notifyJoined().catch((e) => { + logger.error("Failed to notify the host that we joined", e); + }); } // LEAVE prev=false (was joined) => current-true (now left) if (prev && !current) { - widget.api.transport - .send(ElementWidgetActions.HangupCall, {}) - .catch((e) => { - logger.error("Failed to send hangup action", e); - }); + hostBridge.notifyHungUp().catch((e) => { + logger.error("Failed to notify the host that we hung up", e); + }); } }); @@ -845,7 +844,7 @@ interface EnterRTCSessionOptions { * @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The * Matrix RTC mode to use. `sendNotificationType`: Whether and what kind of * notification to send on join. `callIntent`: The kind of call being placed. - * @throws If the widget could not send ElementWidgetActions.JoinCall action. + * @throws If the host could not be told that we are joining. */ // Exported for unit testing export function enterRTCSession( diff --git a/src/state/CallViewModelWidget.test.ts b/src/state/CallViewModelWidget.test.ts index 2f331bd3..dd2e75ab 100644 --- a/src/state/CallViewModelWidget.test.ts +++ b/src/state/CallViewModelWidget.test.ts @@ -6,39 +6,31 @@ Please see LICENSE in the repository root for full details. */ import { it, vi, expect } from "vitest"; -import EventEmitter from "events"; +import { Subject } from "rxjs"; // import * as ComponentsCore from "@livekit/components-core"; import { withCallViewModel } from "./CallViewModel/CallViewModelTestUtils.ts"; import { type CallViewModel } from "./CallViewModel/CallViewModel.ts"; import { constant } from "./Behavior.ts"; import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts"; -import { ElementWidgetActions, widget } from "../widget.ts"; +import { + type HostBridge, + type HostRequest, + nullHostBridge, +} from "../HostBridge.ts"; import { E2eeType } from "../e2ee/e2eeType.ts"; import { MatrixRTCMode } from "../config/ConfigOptions.ts"; vi.mock("@livekit/components-core", { spy: true }); -vi.mock("../widget", () => ({ - ElementWidgetActions: { - HangupCall: "HangupCall", - // Add other actions if needed - }, - widget: { - api: { - transport: { - send: vi.fn().mockResolvedValue(undefined), - reply: vi.fn().mockResolvedValue(undefined), - }, - }, - lazyActions: new EventEmitter(), - }, -})); - it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])( - "expect leave when ElementWidgetActions.HangupCall is called (%s mode)", + "expect leave when the host asks us to hang up (%s mode)", async (mode) => { const pr = Promise.withResolvers(); + const hangUp$ = new Subject>>(); + const hostBridge: HostBridge = { ...nullHostBridge, hangUp$ }; + const reply = vi.fn(); + withCallViewModel(mode)( { remoteParticipants$: constant([aliceParticipant]), @@ -49,25 +41,17 @@ it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])( pr.resolve(s); }); - widget!.lazyActions!.emit( - ElementWidgetActions.HangupCall, - new CustomEvent(ElementWidgetActions.HangupCall, { - detail: { - action: "im.vector.hangup", - api: "toWidget", - data: {}, - requestId: "widgetapi-1761237395918", - widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F", - }, - }), - ); + hangUp$.next({ data: {}, reply }); }, { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + hostBridge, }, ); const source = await pr.promise; expect(source).toBe("user"); + // The host expects to hear back that we acted on its request + expect(reply).toHaveBeenCalledOnce(); }, ); diff --git a/src/state/MuteStates.test.ts b/src/state/MuteStates.test.ts index f594cb05..239f0381 100644 --- a/src/state/MuteStates.test.ts +++ b/src/state/MuteStates.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { BehaviorSubject } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; +import { nullHostBridge } from "../HostBridge"; import { MuteStates, MuteState } from "./MuteStates"; import { type AudioOutputDeviceLabel, @@ -228,10 +229,12 @@ describe("MuteStates", () => { videoInput: aVideoInput(), // other devices are not relevant for this test }); - const muteStates = new MuteStates(testScope, mediaDevices, { - audioEnabled: false, - videoEnabled: false, - }); + const muteStates = new MuteStates( + testScope, + mediaDevices, + { audioEnabled: false, videoEnabled: false }, + nullHostBridge, + ); let latestSyncedState: boolean | null = null; muteStates.video.setHandler(async (enabled: boolean): Promise => { diff --git a/src/state/MuteStates.ts b/src/state/MuteStates.ts index d89cb844..59413b03 100644 --- a/src/state/MuteStates.ts +++ b/src/state/MuteStates.ts @@ -6,14 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type IWidgetApiRequest } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/lib/logger"; import { BehaviorSubject, combineLatest, distinctUntilChanged, firstValueFrom, - fromEvent, map, merge, Observable, @@ -24,7 +22,7 @@ import { } from "rxjs"; import { type MediaDevices, type MediaDevice } from "../state/MediaDevices"; -import { ElementWidgetActions, widget } from "../widget"; +import { type DeviceMuteState, type HostBridge } from "../HostBridge"; import { type ObservableScope } from "./ObservableScope"; import { type Behavior, constant } from "./Behavior"; @@ -216,58 +214,51 @@ export class MuteStates { audioEnabled: boolean; videoEnabled: boolean; }, + hostBridge: HostBridge, ) { - if (widget !== null) { - // Sync our mute states with the hosting client - const widgetApiState$ = combineLatest( - [this.audio.enabled$, this.video.enabled$], - (audio, video) => ({ audio_enabled: audio, video_enabled: video }), - ); - widgetApiState$.pipe(this.scope.bind()).subscribe((state) => { - widget!.api.transport - .send(ElementWidgetActions.DeviceMute, state) - .catch((e) => - logger.warn("Could not send DeviceMute action to widget", e), - ); - }); + // Keep the host informed of our mute state + const muteState$ = combineLatest( + [this.audio.enabled$, this.video.enabled$], + (audio, video): DeviceMuteState => ({ + audio_enabled: audio, + video_enabled: video, + }), + ); + muteState$.pipe(this.scope.bind()).subscribe((state) => { + hostBridge + .notifyDeviceMute(state) + .catch((e) => logger.warn("Could not send mute state to the host", e)); + }); - // Also sync the hosting client's mute states back with ours - const muteActions$ = fromEvent( - widget.lazyActions, - ElementWidgetActions.DeviceMute, - ) as Observable>; - muteActions$ - .pipe( - withLatestFrom( - widgetApiState$, - this.audio.setEnabled$, - this.video.setEnabled$, - ), - this.scope.bind(), - ) - .subscribe(([ev, state, setAudioEnabled, setVideoEnabled]) => { - // First copy the current state into our new state - const newState = { ...state }; - // Update new state if there are any requested changes from the widget - // action in `ev.detail.data`. - if ( - ev.detail.data.audio_enabled != null && - typeof ev.detail.data.audio_enabled === "boolean" && - setAudioEnabled !== null - ) { - newState.audio_enabled = ev.detail.data.audio_enabled; - setAudioEnabled(newState.audio_enabled); - } - if ( - ev.detail.data.video_enabled != null && - typeof ev.detail.data.video_enabled === "boolean" && - setVideoEnabled !== null - ) { - newState.video_enabled = ev.detail.data.video_enabled; - setVideoEnabled(newState.video_enabled); - } - widget!.api.transport.reply(ev.detail, newState); - }); - } + // And apply the changes the host asks for + hostBridge.deviceMute$ + .pipe( + withLatestFrom( + muteState$, + this.audio.setEnabled$, + this.video.setEnabled$, + ), + this.scope.bind(), + ) + .subscribe(([request, state, setAudioEnabled, setVideoEnabled]) => { + // First copy the current state into our new state + const newState = { ...state }; + // Then apply whichever changes the host asked for + if ( + typeof request.data.audio_enabled === "boolean" && + setAudioEnabled !== null + ) { + newState.audio_enabled = request.data.audio_enabled; + setAudioEnabled(newState.audio_enabled); + } + if ( + typeof request.data.video_enabled === "boolean" && + setVideoEnabled !== null + ) { + newState.video_enabled = request.data.video_enabled; + setVideoEnabled(newState.video_enabled); + } + request.reply(newState); + }); } } diff --git a/src/utils/test.ts b/src/utils/test.ts index de74ac53..49ae0a3f 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -63,6 +63,7 @@ import { type MediaDevices } from "../state/MediaDevices"; import { type Behavior, constant } from "../state/Behavior"; import { ObservableScope } from "../state/ObservableScope"; import { MuteStates } from "../state/MuteStates"; +import { nullHostBridge } from "../HostBridge"; import { createLocalUserMedia, type LocalUserMediaViewModel, @@ -577,10 +578,12 @@ export function mockMuteStates( joined$: Observable = of(true), ): MuteStates { const observableScope = new ObservableScope(); - return new MuteStates(observableScope, mockMediaDevices({}), { - audioEnabled: false, - videoEnabled: false, - }); + return new MuteStates( + observableScope, + mockMediaDevices({}), + { audioEnabled: false, videoEnabled: false }, + nullHostBridge, + ); } export class MockConnection extends Connection { From f28d9e9b06693276b7fe1fc0b5bffe53e5afb4f6 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 15:04:24 +0200 Subject: [PATCH 014/154] Stop asking whether Element Call is a widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining reads of the widget global were all asking one of two different questions, so they get two different answers. The app shell — the auth hooks, automatic guest registration, the group call loader's diagnostic and the initial mute state — wants to know whether Element Call was launched as a widget. That is a property of the URL it was launched with, so expose the isWidget that computeUrlParams already computed internally, documented as being for shell use only. The call interface — whether to offer the profile settings tab — wants to know something about its host, so it asks the bridge. A host that can dismiss Element Call owns the user's account, so their profile is not ours to edit; this reuses the close capability as a proxy, with a TODO alongside the others. ClientContext also takes supportsReactions from the bridge rather than checking four widget capabilities itself, which removes widgetApi from InitResult — a field that was always null outside widget mode. Note this changes behaviour for a malformed widget URL: one carrying a widget ID and parent URL but missing the room, user, device or base URL would previously have fallen back to registering a guest user, and will now not. --- src/ClientContext.tsx | 34 +++++-------------------- src/UrlParams.ts | 12 +++++++++ src/auth/useInteractiveRegistration.ts | 7 ++--- src/auth/useRegisterPasswordlessUser.ts | 7 ++--- src/room/InCallView.tsx | 11 +++++--- src/room/RoomPage.tsx | 6 ++--- src/room/useLoadGroupCall.ts | 6 +++-- src/settings/SettingsModal.tsx | 7 +++-- src/utils/spa.ts | 1 - 9 files changed, 45 insertions(+), 46 deletions(-) diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 288665c5..a0d65e64 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -21,9 +21,9 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync"; import { ClientEvent, type MatrixClient } from "matrix-js-sdk"; -import type { WidgetApi } from "matrix-widget-api"; import { ErrorPage } from "./FullScreenView"; import { widget } from "./widget"; +import { useHostBridge } from "./HostBridge"; import { PosthogAnalytics, RegistrationType, @@ -138,6 +138,7 @@ interface Props { export const ClientProvider: FC = ({ children }) => { const navigate = useNavigate(); + const hostBridge = useHostBridge(); // null = signed out, undefined = loading const [initClientState, setInitClientState] = useState< @@ -201,7 +202,6 @@ export const ClientProvider: FC = ({ children }) => { saveSession(session); setInitClientState({ - widgetApi: null, client, passwordlessUser: session.passwordlessUser, }); @@ -307,36 +307,16 @@ export const ClientProvider: FC = ({ children }) => { initClientState.client.on(ClientEvent.Sync, onSync); } - if (initClientState.widgetApi) { - const reactSend = initClientState.widgetApi.hasCapability( - "org.matrix.msc2762.send.event:m.reaction", - ); - const redactSend = initClientState.widgetApi.hasCapability( - "org.matrix.msc2762.send.event:m.room.redaction", - ); - const reactRcv = initClientState.widgetApi.hasCapability( - "org.matrix.msc2762.receive.event:m.reaction", - ); - const redactRcv = initClientState.widgetApi.hasCapability( - "org.matrix.msc2762.receive.event:m.room.redaction", - ); - - if (!reactSend || !reactRcv || !redactSend || !redactRcv) { - logger.warn("Widget does not support reactions"); - setSupportsReactions(false); - } else { - setSupportsReactions(true); - } - } else { - setSupportsReactions(true); - } + if (!hostBridge.supportsReactions) + logger.warn("The host does not permit reactions"); + setSupportsReactions(hostBridge.supportsReactions); return (): void => { if (initClientState.client) { initClientState.client.removeListener(ClientEvent.Sync, onSync); } }; - }, [initClientState, onSync]); + }, [initClientState, onSync, hostBridge]); if (alreadyOpenedErr) { return ; @@ -346,7 +326,6 @@ export const ClientProvider: FC = ({ children }) => { }; export type InitResult = { - widgetApi: WidgetApi | null; client: MatrixClient; passwordlessUser: boolean; }; @@ -357,7 +336,6 @@ async function loadClient(): Promise { logger.log("Using a matryoshka client"); const client = await widget.client; return { - widgetApi: widget.api, client, passwordlessUser: false, }; diff --git a/src/UrlParams.ts b/src/UrlParams.ts index dd83a496..422163fc 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -59,6 +59,17 @@ export interface UrlProperties { // Widget api related params widgetId: string | null; parentUrl: string | null; + /** + * Whether Element Call was started as a widget of a Matrix client, which is + * to say whether it was given a widget ID and a parent to talk to. + * + * Only meaningful to the standalone and widget builds, which own the URL — + * so use it for decisions that belong to the app shell, such as whether + * Element Call is responsible for authenticating the user. Anything the call + * interface itself needs to know about its host should come from the host + * bridge instead. + */ + isWidget: boolean; /** * Anything about what room we're pointed to should be from useRoomIdentifier which * parses the path and resolves alias with respect to the default server name, however @@ -448,6 +459,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { const properties: UrlProperties = { widgetId, parentUrl, + isWidget, // NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl: // what would we do if it were invalid? If the widget API says that's what // the room ID is, then that's what it is. diff --git a/src/auth/useInteractiveRegistration.ts b/src/auth/useInteractiveRegistration.ts index 4972c031..7314c9e4 100644 --- a/src/auth/useInteractiveRegistration.ts +++ b/src/auth/useInteractiveRegistration.ts @@ -17,7 +17,7 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { initClient } from "../utils/matrix"; import { type Session } from "../ClientContext"; import { Config } from "../config/Config"; -import { widget } from "../widget"; +import { useUrlParams } from "../UrlParams"; export const useInteractiveRegistration = ( oldClient?: MatrixClient, @@ -32,6 +32,7 @@ export const useInteractiveRegistration = ( passwordlessUser: boolean, ) => Promise<[MatrixClient, Session]>; } => { + const { isWidget } = useUrlParams(); const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState( undefined, ); @@ -47,7 +48,7 @@ export const useInteractiveRegistration = ( } useEffect(() => { - if (widget) return; + if (isWidget) return; // An empty registerRequest is used to get the privacy policy and recaptcha key. authClient.current!.registerRequest({}).catch((error) => { setPrivacyPolicyUrl( @@ -55,7 +56,7 @@ export const useInteractiveRegistration = ( ); setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key); }); - }, []); + }, [isWidget]); const register = useCallback( async ( diff --git a/src/auth/useRegisterPasswordlessUser.ts b/src/auth/useRegisterPasswordlessUser.ts index c2cbe2d3..27674c62 100644 --- a/src/auth/useRegisterPasswordlessUser.ts +++ b/src/auth/useRegisterPasswordlessUser.ts @@ -12,7 +12,7 @@ import { useClient } from "../ClientContext"; import { useInteractiveRegistration } from "../auth/useInteractiveRegistration"; import { generateRandomName } from "../auth/generateRandomName"; import { useRecaptcha } from "../auth/useRecaptcha"; -import { widget } from "../widget"; +import { useUrlParams } from "../UrlParams"; interface UseRegisterPasswordlessUserType { privacyPolicyUrl?: string; @@ -22,6 +22,7 @@ interface UseRegisterPasswordlessUserType { export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType { const { setClient } = useClient(); + const { isWidget } = useUrlParams(); const { privacyPolicyUrl, recaptchaKey, register } = useInteractiveRegistration(); const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey); @@ -31,7 +32,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType { if (!setClient) { throw new Error("No client context"); } - if (widget) { + if (isWidget) { throw new Error( "Registration was skipped: We should never try to register password-less user in embedded mode.", ); @@ -53,7 +54,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType { throw e; } }, - [execute, reset, register, setClient], + [execute, reset, register, setClient, isWidget], ); return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId }; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index e2edde84..ef1e1f98 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -28,7 +28,6 @@ import { useTranslation } from "react-i18next"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; -import { widget } from "../widget"; import { useHostBridge } from "../HostBridge.ts"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; @@ -251,6 +250,7 @@ export const InCallView: FC = ({ }) => { const logger = rootLogger.getChild("[InCallView]"); const { t } = useTranslation(); + const hostBridge = useHostBridge(); const { sendReaction, toggleRaisedHand } = useReactionsSender(); useWakeLock(); @@ -334,14 +334,17 @@ export const InCallView: FC = ({ const openProfile = useMemo( () => - // Profile settings are unavailable in widget mode - widget === null + // A host that can dismiss us is a host that owns the user's account, so + // their profile is not ours to edit. + // TODO: another use of the close capability as a proxy — see the note in + // GroupCallView. + hostBridge.close === undefined ? (): void => { setSettingsTab("profile"); setSettingsOpen(true); } : null, - [setSettingsTab, setSettingsOpen], + [setSettingsTab, setSettingsOpen, hostBridge], ); const [headerRef, headerBounds] = useMeasure(); diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 00905ac4..33eb80dd 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -29,7 +29,6 @@ import { GroupCallView } from "./GroupCallView"; import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { HomePage } from "../home/HomePage"; -import { widget } from "../widget"; import { useHostBridge } from "../HostBridge.ts"; import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall"; import { LobbyView } from "./LobbyView"; @@ -77,7 +76,7 @@ export const RoomPage: FC = (): ReactNode => { calculateInitialMuteState( urlParams.skipLobby, urlParams.callIntent, - widget !== null, + urlParams.isWidget, ), hostBridge, ), @@ -88,7 +87,7 @@ export const RoomPage: FC = (): ReactNode => { useEffect(() => { // If we've finished loading, are not already authed and we've been given a display name as // a URL param, automatically register a passwordless user - if (!loading && !authenticated && displayName && !widget) { + if (!loading && !authenticated && displayName && !urlParams.isWidget) { setIsRegistering(true); registerPasswordlessUser(displayName) .catch((e) => { @@ -102,6 +101,7 @@ export const RoomPage: FC = (): ReactNode => { loading, authenticated, displayName, + urlParams.isWidget, setIsRegistering, registerPasswordlessUser, ]); diff --git a/src/room/useLoadGroupCall.ts b/src/room/useLoadGroupCall.ts index 8a7617d8..42464bb2 100644 --- a/src/room/useLoadGroupCall.ts +++ b/src/room/useLoadGroupCall.ts @@ -34,7 +34,7 @@ import { EndCallIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { widget } from "../widget"; +import { useUrlParams } from "../UrlParams"; export type GroupCallLoaded = { kind: "loaded"; @@ -132,6 +132,7 @@ export const useLoadGroupCall = ( const [state, setState] = useState({ kind: "loading" }); const activeRoom = useRef(undefined); const { t } = useTranslation(); + const { isWidget } = useUrlParams(); const bannedError = useCallback( (): CallTerminatedMessage => @@ -249,7 +250,7 @@ export const useLoadGroupCall = ( // room already joined so we are done here already. return room!; } - if (widget) + if (isWidget) // in widget mode we never should reach this point. (getRoom should return the room.) throw new Error( "Room not found. The widget-api did not pass over the relevant room events/information.", @@ -373,6 +374,7 @@ export const useLoadGroupCall = ( }, [ bannedError, client, + isWidget, knockRejectError, removeNoticeError, roomIdOrAlias, diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index b2ffef4a..35604d98 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -18,7 +18,7 @@ import { ProfileSettingsTab } from "./ProfileSettingsTab"; import { FeedbackSettingsTab } from "./FeedbackSettingsTab"; import { iosDeviceMenu$ } from "../state/MediaDevices"; import { useMediaDevices } from "../MediaDevicesContext"; -import { widget } from "../widget"; +import { useHostBridge } from "../HostBridge"; import { useSetting, soundEffectVolume as soundEffectVolumeSetting, @@ -123,6 +123,7 @@ export const SettingsModal: FC = ({ // On EC, we decided that it is less confusing for the user if they see those options in the output section // rather than the input section. const { controlledAudioDevices } = useUrlParams(); + const hostBridge = useHostBridge(); // If we are on iOS we will show a button to open the native audio device picker. const iosDeviceMenu = useBehavior(iosDeviceMenu$); @@ -234,7 +235,9 @@ export const SettingsModal: FC = ({ }; const tabs = [audioTab, videoTab]; - if (widget === null) tabs.push(profileTab); + // A host that can dismiss us is a host that owns the user's account, so their + // profile is not ours to edit. + if (hostBridge.close === undefined) tabs.push(profileTab); tabs.push(preferencesTab); if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") { // for full package we want to show the analytics consent checkbox diff --git a/src/utils/spa.ts b/src/utils/spa.ts index e97d7810..b8e959f8 100644 --- a/src/utils/spa.ts +++ b/src/utils/spa.ts @@ -40,7 +40,6 @@ export async function initSPA( try { const client = await initClient(initClientParams, true); return { - widgetApi: null, client, passwordlessUser, }; From eb117249d18fb5f087e7d4ef338d8857c409c1dd Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 15:05:12 +0200 Subject: [PATCH 015/154] Configure analytics explicitly instead of discovering it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PosthogAnalytics read its own configuration out of the environment on first use: the URL parameters, config.json, and the widget global. An embedded Element Call has none of those to offer, and analytics that configure themselves cannot be switched off by a host that does its own reporting. Take an AnalyticsConfig through PosthogAnalytics.configure() instead, called from the initializer once the config has loaded. Unconfigured analytics stay off. Note the two halves of that config are decided differently, and have to be: where the credentials come from depends on the package, but who owns the user's analytics identity depends on how Element Call is running, since the full package can be used as a widget too. Drop the widget check around cryptoVersion, which never did anything — widget mode never initialises crypto, so getCrypto() is already undefined there. Move the tests covering which package reads which credential source onto analyticsConfigFromEnvironment, where that decision now lives. --- src/analytics/PosthogAnalytics.test.ts | 147 ++++++++++++++++--------- src/analytics/PosthogAnalytics.ts | 85 +++++++++----- src/initializer.tsx | 35 ++++++ 3 files changed, 185 insertions(+), 82 deletions(-) diff --git a/src/analytics/PosthogAnalytics.test.ts b/src/analytics/PosthogAnalytics.test.ts index 7c1128ad..ba51ba27 100644 --- a/src/analytics/PosthogAnalytics.test.ts +++ b/src/analytics/PosthogAnalytics.test.ts @@ -22,75 +22,121 @@ import { PosthogAnalytics, } from "./PosthogAnalytics"; import { mockConfig } from "../utils/test"; +import { analyticsConfigFromEnvironment } from "../initializer"; describe("PosthogAnalytics", () => { - describe("embedded package", () => { - beforeAll(() => { - vi.stubEnv("VITE_PACKAGE", "embedded"); - }); - + describe("enablement", () => { beforeEach(() => { - mockConfig({}); - window.location.hash = "#"; PosthogAnalytics.resetInstance(); }); - afterAll(() => { - vi.unstubAllEnvs(); - }); - - it("does not create instance without config value or URL params", () => { + it("stays off until it is configured", () => { expect(PosthogAnalytics.instance.isEnabled()).toBe(false); }); - it("ignores config value and does not create instance", () => { - mockConfig({ - posthog: { - api_host: "https://api.example.com.localhost", - api_key: "api_key", - }, + it("stays off when configured without credentials", () => { + PosthogAnalytics.configure({ matrixBackend: "jssdk" }); + expect(PosthogAnalytics.instance.isEnabled()).toBe(false); + }); + + it("stays off when given only a key", () => { + PosthogAnalytics.configure({ + matrixBackend: "jssdk", + apiKey: "api_key", }); expect(PosthogAnalytics.instance.isEnabled()).toBe(false); }); - it("uses URL params if both set", () => { - window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`; + it("turns on when given both a key and a host", () => { + PosthogAnalytics.configure({ + matrixBackend: "jssdk", + apiKey: "api_key", + apiHost: "https://api.example.com.localhost", + }); expect(PosthogAnalytics.instance.isEnabled()).toBe(true); }); }); - describe("full package", () => { - beforeAll(() => { - vi.stubEnv("VITE_PACKAGE", "full"); - }); - + // Which of the URL and config.json the credentials come from is a deliberate + // policy: an embedder is responsible for its own users' telemetry, so it must + // not pick up the deployment's, and vice versa. + describe("analyticsConfigFromEnvironment", () => { beforeEach(() => { mockConfig({}); window.location.hash = "#"; - PosthogAnalytics.resetInstance(); }); afterAll(() => { vi.unstubAllEnvs(); }); - it("does not create instance without config value", () => { - expect(PosthogAnalytics.instance.isEnabled()).toBe(false); - }); + const urlCredentials = `posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=url_key`; + const configCredentials = { + posthog: { + api_host: "https://config.example.com.localhost", + api_key: "config_key", + }, + }; - it("ignores URL params and does not create instance", () => { - window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`; - expect(PosthogAnalytics.instance.isEnabled()).toBe(false); - }); - - it("creates instance with config value", () => { - mockConfig({ - posthog: { - api_host: "https://api.example.com.localhost", - api_key: "api_key", - }, + describe("embedded package", () => { + beforeAll(() => { + vi.stubEnv("VITE_PACKAGE", "embedded"); }); - expect(PosthogAnalytics.instance.isEnabled()).toBe(true); + + it("has no credentials without URL params", () => { + expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined(); + }); + + it("takes the credentials from the URL", () => { + window.location.hash = `#?${urlCredentials}`; + expect(analyticsConfigFromEnvironment()).toMatchObject({ + apiKey: "url_key", + apiHost: "https://url.example.com.localhost", + }); + }); + + it("ignores the deployment's config", () => { + mockConfig(configCredentials); + expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined(); + }); + }); + + describe("full package", () => { + beforeAll(() => { + vi.stubEnv("VITE_PACKAGE", "full"); + }); + + it("has no credentials without config", () => { + expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined(); + }); + + it("takes the credentials from the config", () => { + mockConfig(configCredentials); + expect(analyticsConfigFromEnvironment()).toMatchObject({ + apiKey: "config_key", + apiHost: "https://config.example.com.localhost", + }); + }); + + it("ignores the URL params", () => { + window.location.hash = `#?${urlCredentials}`; + expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined(); + }); + }); + + // Who owns the user's analytics identity depends on how Element Call is + // running, not on which package it was built as. + it("reports the embedded backend when running as a widget", () => { + vi.stubEnv("VITE_PACKAGE", "full"); + window.location.hash = `#?widgetId=id&parentUrl=${encodeURIComponent("https://host.example.com.localhost")}&posthogUserId=given_id`; + expect(analyticsConfigFromEnvironment()).toMatchObject({ + matrixBackend: "embedded", + hostAnalyticsId: "given_id", + }); + }); + + it("reports the jssdk backend when running standalone", () => { + expect(analyticsConfigFromEnvironment().matrixBackend).toBe("jssdk"); }); }); @@ -204,22 +250,13 @@ describe("PosthogAnalytics", () => { // posthog-js bumps renaming/removing the hook. The filter logic itself is // covered by the applyPrivacyFilters block above. describe("posthog.init wiring", () => { - beforeAll(() => { - vi.stubEnv("VITE_PACKAGE", "full"); - }); - beforeEach(() => { - mockConfig({ - posthog: { - api_host: "https://api.example.com.localhost", - api_key: "api_key", - }, - }); PosthogAnalytics.resetInstance(); - }); - - afterAll(() => { - vi.unstubAllEnvs(); + PosthogAnalytics.configure({ + matrixBackend: "jssdk", + apiKey: "api_key", + apiHost: "https://api.example.com.localhost", + }); }); it("passes events through the privacy filter via before_send", () => { diff --git a/src/analytics/PosthogAnalytics.ts b/src/analytics/PosthogAnalytics.ts index 01a146e0..01a05bfb 100644 --- a/src/analytics/PosthogAnalytics.ts +++ b/src/analytics/PosthogAnalytics.ts @@ -15,7 +15,6 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { type MatrixClient } from "matrix-js-sdk"; import { type Subscription } from "rxjs"; -import { widget } from "../widget"; import { CallEndedTracker, CallStartedTracker, @@ -29,8 +28,6 @@ import { CallConnectDurationTracker, CallReconnectingTracker, } from "./PosthogEvents"; -import { Config } from "../config/Config"; -import { getUrlParams } from "../UrlParams"; import { optInAnalytics } from "../settings/settings"; /* Posthog analytics tracking. @@ -140,6 +137,27 @@ interface PlatformProperties { cryptoVersion?: string; } +/** + * How analytics reporting should be set up, supplied by whoever is starting + * Element Call rather than discovered from the page it happens to be on. + */ +export interface AnalyticsConfig { + /** The PostHog project key. Without one, analytics stay switched off. */ + apiKey?: string; + apiHost?: string; + /** + * How Element Call reaches Matrix. When `embedded`, the host owns the user's + * identity: it supplies the analytics ID, and Element Call must not store one + * in the user's account data. + */ + matrixBackend: "embedded" | "jssdk"; + /** The analytics ID the host has assigned to this user, when embedded. */ + hostAnalyticsId?: string | null; +} + +/** Analytics are off until someone asks for them. */ +const analyticsDisabled: AnalyticsConfig = { matrixBackend: "jssdk" }; + export class PosthogAnalytics { /* Wrapper for Posthog analytics. * 3 modes of anonymity are supported, governed by this.anonymity @@ -167,13 +185,32 @@ export class PosthogAnalytics { private registrationType: RegistrationType = RegistrationType.Guest; private optInListener: Subscription | null = null; + private static analyticsConfig: AnalyticsConfig = analyticsDisabled; + + /** + * Sets up analytics reporting. Must be called before the instance is first + * used; without it, analytics stay switched off. + */ + public static configure(config: AnalyticsConfig): void { + if (this.internalInstance) + // Configuration is read once, when the instance is built, so arriving + // late means analytics are already running unconfigured. + logger.warn( + "Analytics were configured after they had already been started; the new configuration will not take effect", + ); + this.analyticsConfig = config; + } + public static hasInstance(): boolean { return Boolean(this.internalInstance); } public static get instance(): PosthogAnalytics { if (!this.internalInstance) { - this.internalInstance = new PosthogAnalytics(posthog); + this.internalInstance = new PosthogAnalytics( + posthog, + PosthogAnalytics.analyticsConfig, + ); } return this.internalInstance; } @@ -181,20 +218,14 @@ export class PosthogAnalytics { public static resetInstance(): void { // Reset the singleton instance this.internalInstance = null; + this.analyticsConfig = analyticsDisabled; } - private constructor(private readonly posthog: PostHog) { - let apiKey: string | undefined; - let apiHost: string | undefined; - if (import.meta.env.VITE_PACKAGE === "embedded") { - // for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration - apiKey = getUrlParams().posthogApiKey ?? undefined; - apiHost = getUrlParams().posthogApiHost ?? undefined; - } else if (import.meta.env.VITE_PACKAGE === "full") { - // in full package it is the server responsible for the analytics - apiKey = Config.get().posthog?.api_key; - apiHost = Config.get().posthog?.api_host; - } + private constructor( + private readonly posthog: PostHog, + private readonly config: AnalyticsConfig, + ) { + const { apiKey, apiHost } = config; if (apiKey && apiHost) { const beforeSend = (event: CaptureResult | null): CaptureResult | null => @@ -225,15 +256,15 @@ export class PosthogAnalytics { } } - private static getPlatformProperties(): PlatformProperties { + private getPlatformProperties(): PlatformProperties { const appVersion = import.meta.env.VITE_APP_VERSION || "dev"; return { appVersion, - matrixBackend: widget ? "embedded" : "jssdk", + matrixBackend: this.config.matrixBackend, callBackend: "livekit", - cryptoVersion: widget - ? undefined - : window.matrixclient?.getCrypto()?.getVersion(), + // Undefined when Element Call has no crypto of its own, which is the case + // whenever a host is doing the encrypting for it. + cryptoVersion: window.matrixclient?.getCrypto()?.getVersion(), }; } @@ -283,8 +314,8 @@ export class PosthogAnalytics { // different devices to send the same ID. let analyticsID = await this.getAnalyticsId(); try { - if (!analyticsID && !widget) { - // only try setting up a new analytics ID in the standalone app. + if (!analyticsID && this.config.matrixBackend !== "embedded") { + // only mint an analytics ID when we are the ones storing it. // Couldn't retrieve an analytics ID from user settings, so create one and set it on the server. // Note there's a race condition here - if two devices do these steps at the same time, last write @@ -313,8 +344,8 @@ export class PosthogAnalytics { private async getAnalyticsId(): Promise { const client: MatrixClient = window.matrixclient; - if (widget) { - return getUrlParams().posthogUserId; + if (this.config.matrixBackend === "embedded") { + return this.config.hostAnalyticsId ?? null; } else { const accountData = await client.getAccountDataFromServer( PosthogAnalytics.ANALYTICS_EVENT_TYPE, @@ -324,7 +355,7 @@ export class PosthogAnalytics { } private async setAccountAnalyticsId(analyticsID: string): Promise { - if (!widget) { + if (this.config.matrixBackend !== "embedded") { const client = window.matrixclient; // the analytics ID only needs to be set in the standalone version. @@ -362,7 +393,7 @@ export class PosthogAnalytics { // These properties will be subsequently passed in every event. // // This only needs to be done once per page lifetime. Note that getPlatformProperties - this.platformSuperProperties = PosthogAnalytics.getPlatformProperties(); + this.platformSuperProperties = this.getPlatformProperties(); this.registerSuperProperties({ ...this.platformSuperProperties, registrationType: diff --git a/src/initializer.tsx b/src/initializer.tsx index c0e8407e..0927d7ab 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -34,6 +34,10 @@ import { platform } from "./Platform"; import { isFailure } from "./utils/fetch"; import { initializeWidget } from "./widget"; import { enableExtendedLivekitLogs } from "./settings/settings.ts"; +import { + type AnalyticsConfig, + PosthogAnalytics, +} from "./analytics/PosthogAnalytics.ts"; import { i18n } from "./utils/i18n.ts"; // This generates a map of locale names to their URL (based on import.meta.url), which looks like this: @@ -98,6 +102,36 @@ const Backend = { }, } satisfies BackendModule; +/** + * Where analytics reporting is configured from. + * + * Note the two halves are decided differently, and deliberately so. *Where the + * PostHog credentials come from* depends on the package: an embedder passes + * them in through the URL because it is responsible for its own users' + * telemetry, whereas a standalone deployment is configured by whoever operates + * it. *Who owns the user's analytics identity*, on the other hand, depends on + * how Element Call is actually running right now — the full package can be used + * as a widget too. + */ +// Exported for testing +export function analyticsConfigFromEnvironment(): AnalyticsConfig { + const { posthogApiKey, posthogApiHost, posthogUserId, isWidget } = + getUrlParams(); + return { + matrixBackend: isWidget ? "embedded" : "jssdk", + hostAnalyticsId: posthogUserId, + ...(import.meta.env.VITE_PACKAGE === "embedded" + ? { + apiKey: posthogApiKey ?? undefined, + apiHost: posthogApiHost ?? undefined, + } + : { + apiKey: Config.get().posthog?.api_key, + apiHost: Config.get().posthog?.api_host, + }), + }; +} + enum LoadState { None, Loading, @@ -241,6 +275,7 @@ export class Initializer { Config.init().then( () => { seedSettingsFromConfig(Config.get().media_quality); + PosthogAnalytics.configure(analyticsConfigFromEnvironment()); this.loadStates.config = LoadState.Loaded; this.initStep(resolve); }, From f6f47ede62bda74609ff96860760c1223a6e2b75 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 15:14:10 +0200 Subject: [PATCH 016/154] Let a host supply Element Call's client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientProvider found its own client: from the widget API, or by restoring or creating a session. A host that embeds Element Call already has one, and owns the user's session, so accept it as a prop and skip all of that. A supplied client seeds the state synchronously, since there is no session of ours to restore and so nothing to wait for. Also guard the broadcast that shuts down other instances of the app. It protects Element Call's own session and crypto stores, which is why it was already skipped in widget mode — a host's client has the same property, so without this an embedded Element Call would close down the user's other tabs on mount. Adds the first tests for ClientContext, covering both --- src/ClientContext.test.tsx | 69 ++++++++++++++++++++++++++++++++++++++ src/ClientContext.tsx | 33 ++++++++++++++---- 2 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 src/ClientContext.test.tsx diff --git a/src/ClientContext.test.tsx b/src/ClientContext.test.tsx new file mode 100644 index 00000000..909635d8 --- /dev/null +++ b/src/ClientContext.test.tsx @@ -0,0 +1,69 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; +import { type MatrixClient } from "matrix-js-sdk"; +import { type FC } from "react"; + +import { ClientProvider, useClientState } from "./ClientContext"; + +const mockClient = (): MatrixClient => + ({ + on: vi.fn(), + removeListener: vi.fn(), + getUserId: () => "@alice:example.org", + getDeviceId: () => "AAAA", + stopClient: vi.fn(), + }) as Partial as MatrixClient; + +/** Reports what the context says, so a test can assert on it. */ +const ShowClientState: FC = () => { + const state = useClientState(); + if (state === undefined) return loading; + if (state.state === "error") return error; + return ( + {state.authenticated?.client.getUserId() ?? "unauthenticated"} + ); +}; + +test("uses a client supplied by the host without waiting", () => { + const client = mockClient(); + + const { container } = render( + + + + + , + ); + + // Available on the very first render: a supplied client needs no session + // restoring, so there is no loading state to pass through. + expect(container.textContent).toBe("@alice:example.org"); +}); + +test("does not claim exclusive use of storage when given a client", () => { + // The channel is created when the module loads, so spy on the prototype + // rather than trying to replace the global. + const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage"); + + render( + + + + + , + ); + + // The broadcast shuts down other instances to protect Element Call's own + // stores. A host's client brings its own, so there is nothing to protect. + expect(postMessage).not.toHaveBeenCalled(); + + postMessage.mockRestore(); +}); diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index a0d65e64..6ad017f1 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -134,19 +134,36 @@ const loadChannel = interface Props { children: JSX.Element; + /** + * The client Element Call should use. + * + * When a host embeds Element Call it already has a client, and owns the + * user's session; supplying it here means Element Call neither authenticates + * anyone nor manages their session. Left out, Element Call finds a client + * itself — from the widget API, or by restoring or creating a session of its + * own. + */ + client?: MatrixClient; } -export const ClientProvider: FC = ({ children }) => { +export const ClientProvider: FC = ({ children, client }) => { const navigate = useNavigate(); const hostBridge = useHostBridge(); // null = signed out, undefined = loading const [initClientState, setInitClientState] = useState< InitResult | null | undefined - >(undefined); + >( + client === undefined + ? undefined + : // A supplied client belongs to the host, so there is no session of ours + // to restore and nothing to wait for. + { client, passwordlessUser: false }, + ); const initializing = useRef(false); useEffect(() => { + if (client !== undefined) return; // In case the component is mounted, unmounted, and remounted quickly (as // React does in strict mode), we need to make sure not to doubly initialize // the client. @@ -161,7 +178,7 @@ export const ClientProvider: FC = ({ children }) => { }) .catch((err) => logger.error(err)) .finally(() => (initializing.current = false)); - }, []); + }, [client]); const changePassword = useCallback( async (password: string) => { @@ -228,11 +245,13 @@ export const ClientProvider: FC = ({ children }) => { // To protect against multiple sessions writing to the same storage // simultaneously, we send a broadcast message that shuts down all other - // running instances of the app. This isn't necessary if the app is running in - // a widget though, since then it'll be mostly stateless. + // running instances of the app. Element Call only has storage of its own to + // protect when it created the session itself; given a client, or running as a + // widget, it is mostly stateless. + const ownsSession = client === undefined && widget === null; useEffect(() => { - if (!widget) loadChannel?.postMessage({}); - }, []); + if (ownsSession) loadChannel?.postMessage({}); + }, [ownsSession]); const [alreadyOpenedErr, setAlreadyOpenedErr] = useState( undefined, From 182be8676d06df99256efda7414542d9efbca490 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 16:56:09 +0200 Subject: [PATCH 017/154] Return the widget from initializeWidget instead of assigning a global Element Call reached the widget API through a mutable module-level binding, which every consumer imported directly. Nothing outside the app shell needs it any more, so hand it back from initializeWidget and thread it through: the initializer returns it, main passes it to App, and App uses it to build the host bridge and to await the client the host is lending us. ClientContext's loadClient is now only about restoring or creating a session of Element Call's own, since a widget's client arrives as a prop like any other host's would. Also fixes an early return added in the previous commit, which skipped starting the analytics settings listener when a client was supplied. That was harmless until now, but would have stopped analytics following the user's choices in widget mode. sdk/main.ts asked the host to close by hand; it now uses the bridge, which also stops the transport as the app does. --- sdk/main.ts | 26 ++++++++----------- src/App.tsx | 59 ++++++++++++++++++++++++++++--------------- src/ClientContext.tsx | 32 +++++++++++------------ src/initializer.tsx | 8 +++--- src/main.tsx | 3 ++- src/widget.test.ts | 4 +-- src/widget.ts | 24 +++++++++--------- 7 files changed, 84 insertions(+), 72 deletions(-) diff --git a/sdk/main.ts b/sdk/main.ts index de15a575..4a7926a1 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -54,11 +54,7 @@ import { MediaDevices } from "../src/state/MediaDevices"; import { E2eeType } from "../src/e2ee/e2eeType"; import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; -import { - ElementWidgetActions, - widget as _widget, - initializeWidget, -} from "../src/widget"; +import { initializeWidget } from "../src/widget"; import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection"; import { createWidgetHostBridge } from "../src/HostBridge"; @@ -110,8 +106,7 @@ export async function createMatrixRTCSdk( const scope = new ObservableScope(); // widget client - initializeWidget(application, true); - const widget = _widget; + const widget = initializeWidget(application, true); if (!widget) throw Error("No widget. This webapp can only start as a widget"); const client = await widget.client; const hostBridge = createWidgetHostBridge(widget); @@ -294,18 +289,17 @@ export async function createMatrixRTCSdk( }); await leaveResolver.promise; logger.info("send Unstick"); - await widget.api + await hostBridge .setAlwaysOnScreen(false) - .catch((e) => - logger.error( - "Failed to set call widget `alwaysOnScreen` to false", - e, - ), + .catch((e: unknown) => + logger.error("Failed to set `alwaysOnScreen` to false", e), ); logger.info("send Close"); - await widget.api.transport - .send(ElementWidgetActions.Close, {}) - .catch((e) => logger.error("Failed to send close action", e)); + await hostBridge + .close?.() + .catch((e: unknown) => + logger.error("Failed to ask the host to close", e), + ); }; // schedule close first and then leave (scope.end) diff --git a/src/App.tsx b/src/App.tsx index 1d51f959..8d272989 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/lib/logger"; +import { type MatrixClient } from "matrix-js-sdk"; import { I18nextProvider } from "react-i18next"; import { HomePage } from "./home/HomePage"; @@ -19,7 +20,7 @@ import { RoomPage } from "./room/RoomPage"; import { ClientProvider } from "./ClientContext"; import { ErrorPage, LoadingPage } from "./FullScreenView"; import { Initializer } from "./initializer"; -import { widget } from "./widget"; +import { type WidgetHelpers } from "./widget"; import { useTheme } from "./useTheme"; import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { type AppViewModel } from "./state/AppViewModel"; @@ -81,9 +82,11 @@ const MaybeAppBar: FC = ({ children }) => { interface Props { vm: AppViewModel; + /** A point of access to the widget API, if running as a widget. */ + widget: WidgetHelpers | null; } -export const App: FC = ({ vm }) => { +export const App: FC = ({ vm, widget }) => { // The standalone build has no host; the widget build's host is the client it // is a widget of. const hostBridge = useInitial(() => @@ -100,26 +103,40 @@ export const App: FC = ({ vm }) => { .catch(logger.error); }); - const content = loaded ? ( - - - - } - > - - } /> - } /> - } /> - } /> - - - - - - ) : ( - + // As a widget, the client comes from the host over the widget API. Standalone, + // Element Call finds one itself, so there is nothing to wait for here. + const [widgetClient, setWidgetClient] = useState( + undefined, ); + useEffect(() => { + if (widget === null) return; + widget.client + .then(setWidgetClient) + .catch((e) => logger.error("Failed to obtain the host's client", e)); + }, [widget]); + const clientReady = widget === null || widgetClient !== undefined; + + const content = + loaded && clientReady ? ( + + + + } + > + + } /> + } /> + } /> + } /> + + + + + + ) : ( + + ); return ( diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 6ad017f1..388beb8b 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -22,7 +22,6 @@ import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync"; import { ClientEvent, type MatrixClient } from "matrix-js-sdk"; import { ErrorPage } from "./FullScreenView"; -import { widget } from "./widget"; import { useHostBridge } from "./HostBridge"; import { PosthogAnalytics, @@ -163,7 +162,12 @@ export const ClientProvider: FC = ({ children, client }) => { const initializing = useRef(false); useEffect(() => { - if (client !== undefined) return; + if (client !== undefined) { + // Nothing to load, but analytics still need to follow the user's choices. + if (PosthogAnalytics.instance.isEnabled()) + PosthogAnalytics.instance.startListeningToSettingsChanges(); + return; + } // In case the component is mounted, unmounted, and remounted quickly (as // React does in strict mode), we need to make sure not to doubly initialize // the client. @@ -246,9 +250,9 @@ export const ClientProvider: FC = ({ children, client }) => { // To protect against multiple sessions writing to the same storage // simultaneously, we send a broadcast message that shuts down all other // running instances of the app. Element Call only has storage of its own to - // protect when it created the session itself; given a client, or running as a - // widget, it is mostly stateless. - const ownsSession = client === undefined && widget === null; + // protect when it created the session itself; given a client — by a host, or + // over the widget API — it is mostly stateless. + const ownsSession = client === undefined; useEffect(() => { if (ownsSession) loadChannel?.postMessage({}); }, [ownsSession]); @@ -349,19 +353,13 @@ export type InitResult = { passwordlessUser: boolean; }; +/** + * Restores or creates a session of Element Call's own. Only reached when no + * client was supplied for it to use. + */ async function loadClient(): Promise { - if (widget) { - // We're inside a widget, so let's engage *matryoshka mode* - logger.log("Using a matryoshka client"); - const client = await widget.client; - return { - client, - passwordlessUser: false, - }; - } else { - const { initSPA } = await import("./utils/spa"); - return initSPA(loadSession, clearSession); - } + const { initSPA } = await import("./utils/spa"); + return initSPA(loadSession, clearSession); } export interface Session { diff --git a/src/initializer.tsx b/src/initializer.tsx index 0927d7ab..ee3ee9bd 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -32,7 +32,7 @@ import { Config } from "./config/Config"; import { seedSettingsFromConfig } from "./settings/settings"; import { platform } from "./Platform"; import { isFailure } from "./utils/fetch"; -import { initializeWidget } from "./widget"; +import { initializeWidget, type WidgetHelpers } from "./widget"; import { enableExtendedLivekitLogs } from "./settings/settings.ts"; import { type AnalyticsConfig, @@ -155,8 +155,8 @@ export class Initializer { return !!Initializer.internalInstance?.isInitialized; } - public static async initBeforeReact(): Promise { - initializeWidget(); + public static async initBeforeReact(): Promise { + const widget = initializeWidget(); const polyfills: Promise[] = []; if (shouldPolyfillSegmenter()) { @@ -243,6 +243,8 @@ export class Initializer { }); window.setLKLogLevel = setLKLogLevel; + + return widget; } public static init(): Promise | null { diff --git a/src/main.tsx b/src/main.tsx index 03c74c94..b2f79f34 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -49,7 +49,7 @@ if (fatalError !== null) { } Initializer.initBeforeReact() - .then(() => { + .then((widget) => { const { controlledAudioDevices, callIntent } = getUrlParams(); root.render( @@ -60,6 +60,7 @@ Initializer.initBeforeReact() callIntent, }) } + widget={widget} /> , ); diff --git a/src/widget.test.ts b/src/widget.test.ts index 3618fb50..c645c3b3 100644 --- a/src/widget.test.ts +++ b/src/widget.test.ts @@ -9,7 +9,7 @@ import { describe, expect, vi, it, beforeEach } from "vitest"; import { createRoomWidgetClient, EventType } from "matrix-js-sdk"; import { getUrlParams } from "./UrlParams"; -import { initializeWidget, widget } from "./widget"; +import { initializeWidget } from "./widget"; import { Config } from "./config/Config"; import { ElementCallReactionEventType } from "./reactions"; @@ -42,7 +42,7 @@ beforeEach(() => { describe("widget", () => { it("should create an embedded client with the correct params", () => { - initializeWidget("ANYRTCAPP"); + const widget = initializeWidget("ANYRTCAPP"); expect(getUrlParams()).toStrictEqual({ widgetId: "id", diff --git a/src/widget.ts b/src/widget.ts index af6f28b6..b35e335b 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -58,21 +58,21 @@ export interface WidgetHelpers { } /** - * A point of access to the widget API, if the app is running as a widget. This - * is initialized with `initializeWidget`. This should happen at the top level because the widget messaging - * needs to be set up ASAP on load to ensure it doesn't miss any requests. - */ -export let widget: WidgetHelpers | null = null; - -/** - * Should be called as soon as possible on app start. (In the initilizer before react) + * Connects to the widget API, if Element Call is running as a widget. + * + * Should be called as soon as possible on app start (in the initializer, before + * React), because the widget messaging needs to be set up ASAP on load to + * ensure it doesn't miss any requests. + * + * @returns A point of access to the widget API, or null if Element Call is not + * running as a widget. */ // this needs to be a seperate call and cannot be done on import to allow us to spy on methods in here before // execution. export const initializeWidget = ( rtcApplication: string = "m.call", sendRoomEvents = false, -): void => { +): WidgetHelpers | null => { try { const { widgetId, @@ -202,14 +202,14 @@ export const initializeWidget = ( return client; }; - widget = { api, lazyActions, client: clientPromise() }; + return { api, lazyActions, client: clientPromise() }; } else { if (import.meta.env.MODE !== "test") logger.info("No widget API available"); - widget = null; + return null; } } catch (e) { logger.warn("Continuing without the widget API", e); - widget = null; + return null; } }; From 6b5f396e1ebb3a6476bc182c090aa5af7c8a4cf9 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 19:26:33 +0200 Subject: [PATCH 018/154] Share the mapping from parameters to call view model options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK stopped sending join notifications: it threaded callIntent into createCallViewModel$ but not its pair sendNotificationType, which enterRTCSession used to read for itself, so an explicit ?sendNotificationType=ring — or an intent that implies one — no longer reached joinRTCSession. The mechanism is worth fixing rather than the instance. The defaults on CallViewModelOptions describe a standalone Element Call, so a widget caller that misses a field gets standalone behaviour rather than an error, and the SDK is only ever a widget. Give both callers one shared mapping so they cannot drift, and cover the whole chain from URL to options in tests. autoLeaveWhenOthersLeft and waitForCallPickup stay out of it: the view model never read those from the parameters, so enabling them for the SDK would be a change in its behaviour rather than a fix. --- sdk/main.ts | 11 +++-- src/room/InCallView.tsx | 20 ++------- src/state/CallViewModel/CallViewModel.test.ts | 44 +++++++++++++++++-- src/state/CallViewModel/CallViewModel.ts | 36 ++++++++++++++- 4 files changed, 87 insertions(+), 24 deletions(-) diff --git a/sdk/main.ts b/sdk/main.ts index 4a7926a1..6347d142 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -46,7 +46,10 @@ import { // Can this be done in the tsconfig.json import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types"; import { type Behavior, constant } from "../src/state/Behavior"; -import { createCallViewModel$ } from "../src/state/CallViewModel/CallViewModel"; +import { + callViewModelOptionsFromParams, + createCallViewModel$, +} from "../src/state/CallViewModel/CallViewModel"; import { ObservableScope } from "../src/state/ObservableScope"; import { getUrlParams } from "../src/UrlParams"; import { MuteStates } from "../src/state/MuteStates"; @@ -113,7 +116,8 @@ export async function createMatrixRTCSdk( logger.info("client created"); // url params - const { roomId, controlledAudioDevices, callIntent } = getUrlParams(); + const urlParams = getUrlParams(); + const { roomId, controlledAudioDevices, callIntent } = urlParams; if (roomId === null) throw Error("could not get roomId from url params"); const room = client.getRoom(roomId); if (room === null) throw Error("could not get room from client"); @@ -144,10 +148,9 @@ export async function createMatrixRTCSdk( mediaDevices, muteStates, { + ...callViewModelOptionsFromParams(urlParams), encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, hostBridge, - controlledAudioDevices, - callIntent, }, of({}), of({}), diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index ef1e1f98..97fb7543 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -41,6 +41,7 @@ import { type MatrixInfo } from "./VideoPreview"; import { InviteButton } from "../button/InviteButton"; import { type CallViewModel, + callViewModelOptionsFromParams, createCallViewModel$, } from "../state/CallViewModel/CallViewModel.ts"; import { Grid, type TileProps } from "../grid/Grid"; @@ -123,16 +124,8 @@ export const ActiveCall: FC = (props) => { rootLogger.info("START CALL VIEW SCOPE"); const scope = new ObservableScope(); const reactionsReader = new ReactionsReader(scope, props.rtcSession); - const { - autoLeaveWhenOthersLeft, - waitForCallPickup, - sendNotificationType, - controlledAudioDevices, - header, - showControls, - hideScreensharing, - callIntent, - } = urlParams; + const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } = + urlParams; const vm = createCallViewModel$( scope, @@ -141,17 +134,12 @@ export const ActiveCall: FC = (props) => { mediaDevices, props.muteStates, { + ...callViewModelOptionsFromParams(urlParams), encryptionSystem: props.e2eeSystem, hostBridge, autoLeaveWhenOthersLeft, waitForCallPickup: waitForCallPickup && sendNotificationType === "ring", matrixRTCMode$: matrixRTCModeSetting.value$, - controlledAudioDevices, - header, - showControls, - hideScreensharing, - sendNotificationType, - callIntent, }, reactionsReader.raisedHands$, reactionsReader.reactions$, diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 7a27fd3e..c7783d15 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -68,6 +68,8 @@ import { } from "./CallViewModelTestUtils.ts"; import { MatrixRTCMode } from "../../config/ConfigOptions.ts"; import { initializeWidget } from "../../widget.ts"; +import { computeUrlParams } from "../../UrlParams.ts"; +import { callViewModelOptionsFromParams } from "./CallViewModel.ts"; initializeWidget(); @@ -83,9 +85,6 @@ vi.mock("livekit-client/e2ee-worker?worker"); vi.mock("../e2ee/matrixKeyProvider"); -const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); -vi.mock("../UrlParams", () => ({ getUrlParams })); - const getPlatform = vi.hoisted(() => vi.fn(() => "desktop")); vi.mock("../../Platform", () => ({ get platform(): string { @@ -1701,3 +1700,42 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { }); }); }); + +describe("callViewModelOptionsFromParams", () => { + // The defaults on CallViewModelOptions describe a standalone Element Call, so + // a widget caller that drops one of these gets standalone behaviour rather + // than an error. These check the whole chain from URL to options, which is + // where that went wrong for the SDK. + const widgetUrl = (extra: string): string => + `#?widgetId=id&parentUrl=${encodeURIComponent("http://parent")}&${extra}`; + + it("carries an explicitly requested notification type", () => { + const params = computeUrlParams("", widgetUrl("sendNotificationType=ring")); + expect(callViewModelOptionsFromParams(params).sendNotificationType).toBe( + "ring", + ); + }); + + it("carries the notification type an intent implies", () => { + const params = computeUrlParams("", widgetUrl("intent=start_call_dm")); + expect(callViewModelOptionsFromParams(params).sendNotificationType).toBe( + "ring", + ); + }); + + it("carries hideScreensharing", () => { + const params = computeUrlParams("", widgetUrl("hideScreensharing=true")); + expect(callViewModelOptionsFromParams(params).hideScreensharing).toBe(true); + }); + + it("carries controlledAudioDevices and the call intent", () => { + const params = computeUrlParams( + "", + widgetUrl("controlledAudioDevices=true&intent=start_call_voice"), + ); + expect(callViewModelOptionsFromParams(params)).toMatchObject({ + controlledAudioDevices: true, + callIntent: "audio", + }); + }); +}); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index b40a4523..85ba2757 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -86,7 +86,7 @@ import { constant, type Behavior } from "../Behavior"; import { E2eeType } from "../../e2ee/e2eeType"; import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider"; import { type MuteStates } from "../MuteStates"; -import { HeaderStyle } from "../../UrlParams"; +import { HeaderStyle, type UrlParams } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { type HostBridge, nullHostBridge } from "../../HostBridge"; import { @@ -216,6 +216,40 @@ export interface CallViewModelOptions { toggleScreensharing?: () => void; } +/** + * The options {@link createCallViewModel$} takes from the parameters Element + * Call was started with. + * + * Callers share this rather than picking the fields out themselves. The + * defaults on {@link CallViewModelOptions} describe a standalone Element Call, + * so a widget or embedded caller that misses one does not get an error — it + * quietly gets standalone behaviour instead. + * + * Note `autoLeaveWhenOthersLeft` and `waitForCallPickup` are deliberately not + * here: unlike these, the view model never read them from the parameters + * itself, so they remain the caller's decision. + */ +export function callViewModelOptionsFromParams( + params: UrlParams, +): Pick< + CallViewModelOptions, + | "controlledAudioDevices" + | "header" + | "showControls" + | "hideScreensharing" + | "sendNotificationType" + | "callIntent" +> { + return { + controlledAudioDevices: params.controlledAudioDevices, + header: params.header, + showControls: params.showControls, + hideScreensharing: params.hideScreensharing, + sendNotificationType: params.sendNotificationType, + callIntent: params.callIntent, + }; +} + // Do not play any sounds if the participant count has exceeded this // number. export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8; From c807b8ef7d20f37aa86eb1aa1c110d73eef12ad0 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 19:26:59 +0200 Subject: [PATCH 019/154] Always stop the widget transport when closing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close() sent io.element.close and then stopped the transport, so a rejected send skipped the stop. Both call sites this replaced stopped it unconditionally — ErrorView in a finally, GroupCallView outside its try/catch — because a host that never acknowledges the request would otherwise leave the messaging live and the close button doing nothing. Restore that with a finally. --- src/HostBridge.test.ts | 32 +++++++++++++++++++++++++++++++- src/HostBridge.ts | 10 ++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/HostBridge.test.ts b/src/HostBridge.test.ts index 2ddee989..e93fcfb1 100644 --- a/src/HostBridge.test.ts +++ b/src/HostBridge.test.ts @@ -10,7 +10,7 @@ import { type WidgetApi } from "matrix-widget-api"; import EventEmitter from "events"; import { createWidgetHostBridge, nullHostBridge } from "./HostBridge"; -import { type WidgetHelpers } from "./widget"; +import { ElementWidgetActions, type WidgetHelpers } from "./widget"; function mockWidget(api: Partial): WidgetHelpers { return { @@ -60,6 +60,36 @@ describe("createWidgetHostBridge", () => { }); }); + describe("close", () => { + test("asks the host to close, then stops the transport", async () => { + const transport = { + send: vi.fn().mockResolvedValue(undefined), + stop: vi.fn(), + }; + const bridge = createWidgetHostBridge(mockWidget({ transport } as never)); + + await bridge.close!(); + + expect(transport.send).toHaveBeenCalledWith( + ElementWidgetActions.Close, + {}, + ); + expect(transport.stop).toHaveBeenCalledOnce(); + }); + + test("stops the transport even when the host refuses to close", async () => { + const transport = { + send: vi.fn().mockRejectedValue(new Error("no")), + stop: vi.fn(), + }; + const bridge = createWidgetHostBridge(mockWidget({ transport } as never)); + + // Leaving the messaging live would leave the close affordance dead + await expect(bridge.close!()).rejects.toThrow("no"); + expect(transport.stop).toHaveBeenCalledOnce(); + }); + }); + describe("supportsReactions", () => { const capabilities = [ "org.matrix.msc2762.send.event:m.reaction", diff --git a/src/HostBridge.ts b/src/HostBridge.ts index f31b5a4a..bb078672 100644 --- a/src/HostBridge.ts +++ b/src/HostBridge.ts @@ -163,8 +163,14 @@ export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge { notifyDeviceMute: async (state) => send(ElementWidgetActions.DeviceMute, state), close: async () => { - await send(ElementWidgetActions.Close); - widget.api.transport.stop(); + try { + await send(ElementWidgetActions.Close); + } finally { + // Stop regardless of whether the host acknowledged the request. A host + // that rejects or never answers would otherwise leave the messaging + // live, and the close affordance doing nothing at all. + widget.api.transport.stop(); + } }, themeChange$: requests(WidgetApiToWidgetAction.ThemeChange), join$: requests(ElementWidgetActions.JoinCall), From b7efa02adcc3bc9de04716842050435b6d3bb5a8 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 19:27:44 +0200 Subject: [PATCH 020/154] review: Follow a swapped client --- src/ClientContext.test.tsx | 29 +++++++++++++++++++++++++++-- src/ClientContext.tsx | 9 ++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/ClientContext.test.tsx b/src/ClientContext.test.tsx index 909635d8..d279a87e 100644 --- a/src/ClientContext.test.tsx +++ b/src/ClientContext.test.tsx @@ -13,11 +13,11 @@ import { type FC } from "react"; import { ClientProvider, useClientState } from "./ClientContext"; -const mockClient = (): MatrixClient => +const mockClient = (userId = "@alice:example.org"): MatrixClient => ({ on: vi.fn(), removeListener: vi.fn(), - getUserId: () => "@alice:example.org", + getUserId: () => userId, getDeviceId: () => "AAAA", stopClient: vi.fn(), }) as Partial as MatrixClient; @@ -67,3 +67,28 @@ test("does not claim exclusive use of storage when given a client", () => { postMessage.mockRestore(); }); + +test("follows the client when the host swaps it", () => { + const first = mockClient(); + const second = mockClient("@bob:example.org"); + + const { container, rerender } = render( + + + + + , + ); + expect(container.textContent).toBe("@alice:example.org"); + + // A host that re-authenticates hands us a new client on a mounted component + rerender( + + + + + , + ); + + expect(container.textContent).toBe("@bob:example.org"); +}); diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 388beb8b..ced48d44 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -163,7 +163,14 @@ export const ClientProvider: FC = ({ children, client }) => { const initializing = useRef(false); useEffect(() => { if (client !== undefined) { - // Nothing to load, but analytics still need to follow the user's choices. + // Nothing to load, but a host may hand us a different client later — on + // re-authenticating, say — so follow whichever one it has given us. + setInitClientState((current) => + current?.client === client + ? current + : { client, passwordlessUser: false }, + ); + // Analytics still need to follow the user's choices. if (PosthogAnalytics.instance.isEnabled()) PosthogAnalytics.instance.startListeningToSettingsChanges(); return; From e68d1d1b5fe819b96b3db1452bfe74f107881d36 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 19:28:14 +0200 Subject: [PATCH 021/154] Say what the root element seam cannot do yet RootElementContext's documentation promised that Element Call confines its decoration to the given element, but several selectors still name body directly and the initializer writes data-platform onto it. A non-body root is decorated correctly and styled incorrectly, with no error to show for it, so the documentation should say so until the stylesheets are scoped. --- src/RootElementContext.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts index b97b1779..b46573d3 100644 --- a/src/RootElementContext.ts +++ b/src/RootElementContext.ts @@ -12,9 +12,17 @@ import { createContext, use } from "react"; * * Element Call decorates this element with the theme, layout and background * attributes its stylesheets key off, and portals its modals into it. When - * Element Call owns the page this is simply the document body; when it is - * embedded in a host application it is the container the host mounted it into, - * so that Element Call does not reach outside its own subtree. + * Element Call owns the page this is simply the document body; the intent is + * that when embedded in a host application it becomes the container the host + * mounted it into, so that Element Call does not reach outside its own subtree. + * + * That intent is not yet achievable: several selectors still name `body` + * directly — `body[data-background="gradient"]` and `body[data-platform=…]` in + * `index.css`, and `body[data-platform="ios"]` in `AppBar.module.css` and + * `Modal.module.css` — and `Initializer.initBeforeReact` writes + * `data-platform` straight onto the body. So anything other than the body will + * be decorated correctly and styled incorrectly, silently. Until those are + * scoped, treat this as preparation rather than a working seam. */ const RootElementContext = createContext(null); From 2db24e28687e4c0b65bf67e799a19ebc4fff02b1 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Wed, 2 Sep 2026 18:46:12 +0100 Subject: [PATCH 022/154] Fix periodic rageshake log flush never running The throttled flush callback returned this.flush instead of calling it (regressed in #2607), so logs were only persisted to IndexedDB on rageshake submission or beforeunload. When the host removes the widget iframe at hangup, the whole call's logs were lost, so a rageshake filed from a later call carries nothing from the affected one. --- src/settings/rageshake.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/settings/rageshake.ts b/src/settings/rageshake.ts index b9ecbff5..8fe759a8 100644 --- a/src/settings/rageshake.ts +++ b/src/settings/rageshake.ts @@ -204,10 +204,13 @@ class IndexedDBLogStore { // Throttled function to flush logs. We use throttle rather // than debounce as we want logs to be written regularly, otherwise // if there's a constant stream of logging, we'd never write anything. - private throttledFlush = throttle(() => this.flush, MAX_FLUSH_INTERVAL_MS, { - leading: false, - trailing: true, - }); + private throttledFlush = throttle( + () => { + this.flush().catch((e) => logger.error("Failed to flush logs", e)); + }, + MAX_FLUSH_INTERVAL_MS, + { leading: false, trailing: true }, + ); /** * Flush logs to disk. From 873dfdbfbd77e0d80dfba7d29e35223ee251d336 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 19:46:44 +0200 Subject: [PATCH 023/154] Drop the unused root element provider RootElementProvider was exported but never used: nothing supplies a root element, so every consumer falls back to the document body. Knip reports it, which is what is failing CI. M1 adds a provider back along with the component that mounts Element Call into a container. Until then there is nothing to provide. --- src/RootElementContext.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts index b46573d3..d8211c74 100644 --- a/src/RootElementContext.ts +++ b/src/RootElementContext.ts @@ -24,10 +24,11 @@ import { createContext, use } from "react"; * be decorated correctly and styled incorrectly, silently. Until those are * scoped, treat this as preparation rather than a working seam. */ +// No provider is exported yet: nothing supplies a root element, so every +// consumer falls back to the document body. M1 adds one along with the +// component that mounts Element Call into a container. const RootElementContext = createContext(null); -export const RootElementProvider = RootElementContext.Provider; - /** * The element Element Call should decorate and portal into. * From 698a98a9c8869ddafc5b2d20f218d2ec58a571c3 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Wed, 2 Sep 2026 18:55:18 +0100 Subject: [PATCH 024/154] Test that logs are flushed to IndexedDB periodically --- src/settings/rageshake.flush.test.ts | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/settings/rageshake.flush.test.ts diff --git a/src/settings/rageshake.flush.test.ts b/src/settings/rageshake.flush.test.ts new file mode 100644 index 00000000..2bbe120c --- /dev/null +++ b/src/settings/rageshake.flush.test.ts @@ -0,0 +1,52 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { afterEach, expect, it, vi } from "vitest"; + +import { init as initRageshake } from "./rageshake"; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it("flushes logs to IndexedDB periodically without an explicit flush", async () => { + vi.useFakeTimers(); + const add = vi.fn(); + const txn = { + oncomplete: undefined as (() => void) | undefined, + onerror: undefined, + objectStore: (name: string) => + name === "logs" + ? { + add: (entry: unknown): void => { + add(entry); + queueMicrotask(() => txn.oncomplete?.()); + }, + } + : { put: vi.fn() }, + }; + const open = (): unknown => { + const req = { + result: { transaction: () => txn }, + onsuccess: undefined as (() => void) | undefined, + }; + queueMicrotask(() => req.onsuccess?.()); + return req; + }; + vi.stubGlobal("indexedDB", { open }); + + await initRageshake(); + global.mx_rage_logger.log(1, "test", "hello from the buffer"); + expect(add).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(2000); + expect(add).toHaveBeenCalledOnce(); + expect(add.mock.calls[0][0]).toMatchObject({ + lines: expect.stringContaining("hello from the buffer"), + }); +}); From ebb7fe07b749a935aa5498657dc5e278d0ba96d9 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 11:47:35 +0200 Subject: [PATCH 025/154] add more test --- src/ClientContext.test.tsx | 23 +++- src/HostBridge.test.ts | 161 ++++++++++++++++++++++++- src/analytics/PosthogAnalytics.test.ts | 85 +++++++++++++ src/e2ee/sharedKeyManagement.test.ts | 36 ++++++ src/state/MediaDevices.test.ts | 59 +++++++++ src/utils/errors.test.ts | 62 ++++++++++ 6 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 src/e2ee/sharedKeyManagement.test.ts create mode 100644 src/state/MediaDevices.test.ts create mode 100644 src/utils/errors.test.ts diff --git a/src/ClientContext.test.tsx b/src/ClientContext.test.tsx index d279a87e..65ff03d0 100644 --- a/src/ClientContext.test.tsx +++ b/src/ClientContext.test.tsx @@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { expect, test, vi } from "vitest"; -import { render } from "@testing-library/react"; +import { render, waitFor } from "@testing-library/react"; import { BrowserRouter } from "react-router-dom"; import { type MatrixClient } from "matrix-js-sdk"; import { type FC } from "react"; @@ -92,3 +92,24 @@ test("follows the client when the host swaps it", () => { expect(container.textContent).toBe("@bob:example.org"); }); + +test("finds a client of its own when the host supplies none", async () => { + const client = mockClient(); + vi.doMock("./utils/spa", () => ({ + initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }), + })); + + const { container } = render( + + + + + , + ); + + // Nothing to show until a session has been restored or created + expect(container.textContent).toBe("loading"); + await waitFor(() => expect(container.textContent).toBe("@alice:example.org")); + + vi.doUnmock("./utils/spa"); +}); diff --git a/src/HostBridge.test.ts b/src/HostBridge.test.ts index e93fcfb1..4ab42a13 100644 --- a/src/HostBridge.test.ts +++ b/src/HostBridge.test.ts @@ -6,10 +6,16 @@ Please see LICENSE in the repository root for full details. */ import { describe, expect, test, vi } from "vitest"; -import { type WidgetApi } from "matrix-widget-api"; +import { type WidgetApi, WidgetApiToWidgetAction } from "matrix-widget-api"; import EventEmitter from "events"; -import { createWidgetHostBridge, nullHostBridge } from "./HostBridge"; +import { type Observable } from "rxjs"; + +import { + createWidgetHostBridge, + type HostBridge, + nullHostBridge, +} from "./HostBridge"; import { ElementWidgetActions, type WidgetHelpers } from "./widget"; function mockWidget(api: Partial): WidgetHelpers { @@ -20,7 +26,158 @@ function mockWidget(api: Partial): WidgetHelpers { } as unknown as WidgetHelpers; } +/** A widget whose transport records what Element Call sends it. */ +function mockTransport(): { + send: ReturnType; + reply: ReturnType; + stop: ReturnType; +} { + return { + send: vi.fn().mockResolvedValue(undefined), + reply: vi.fn(), + stop: vi.fn(), + }; +} + describe("createWidgetHostBridge", () => { + describe("telling the host what Element Call is doing", () => { + test("asks to be kept on screen, and to stop being", async () => { + const setAlwaysOnScreen = vi.fn().mockResolvedValue(true); + const bridge = createWidgetHostBridge(mockWidget({ setAlwaysOnScreen })); + + await bridge.setAlwaysOnScreen(true); + await bridge.setAlwaysOnScreen(false); + + expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(1, true); + expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(2, false); + }); + + test("reports that it has loaded", async () => { + const sendContentLoaded = vi.fn().mockResolvedValue(undefined); + const bridge = createWidgetHostBridge(mockWidget({ sendContentLoaded })); + + await bridge.contentLoaded(); + + expect(sendContentLoaded).toHaveBeenCalledOnce(); + }); + + test.each([ + ["notifyJoined", ElementWidgetActions.JoinCall, {}], + ["notifyHungUp", ElementWidgetActions.HangupCall, {}], + ] as const)("sends %s as %s", async (method, action, payload) => { + const transport = mockTransport(); + const bridge = createWidgetHostBridge(mockWidget({ transport } as never)); + + await bridge[method](); + + expect(transport.send).toHaveBeenCalledWith(action, payload); + }); + + test("sends the mute state the host needs to mirror", async () => { + const transport = mockTransport(); + const bridge = createWidgetHostBridge(mockWidget({ transport } as never)); + + await bridge.notifyDeviceMute({ + audio_enabled: true, + video_enabled: false, + }); + + expect(transport.send).toHaveBeenCalledWith( + ElementWidgetActions.DeviceMute, + { audio_enabled: true, video_enabled: false }, + ); + }); + }); + + describe("relaying what the host asks for", () => { + /** Emits a widget action the way widget.ts does, and returns the event. */ + function askHost( + widget: WidgetHelpers, + action: string, + data: unknown, + ): CustomEvent { + const ev = new CustomEvent(action, { detail: { action, data } }); + widget.lazyActions.emit(action, ev); + return ev; + } + + // Selectors rather than keys, so each stream keeps its own request type + const inboundStreams: [ + name: string, + select: (bridge: HostBridge) => Observable<{ data: unknown }>, + action: string, + ][] = [ + [ + "themeChange$", + (bridge) => bridge.themeChange$, + WidgetApiToWidgetAction.ThemeChange, + ], + ["join$", (bridge) => bridge.join$, ElementWidgetActions.JoinCall], + ["hangUp$", (bridge) => bridge.hangUp$, ElementWidgetActions.HangupCall], + [ + "deviceMute$", + (bridge) => bridge.deviceMute$, + ElementWidgetActions.DeviceMute, + ], + ]; + + test.each(inboundStreams)( + "surfaces %s with the host's data", + (_name, select, action) => { + const widget = mockWidget({ transport: mockTransport() } as never); + const bridge = createWidgetHostBridge(widget); + const seen: unknown[] = []; + select(bridge).subscribe((request) => seen.push(request.data)); + + askHost(widget, action, { some: "payload" }); + + expect(seen).toEqual([{ some: "payload" }]); + }, + ); + + test("replies to the host against the request it made", () => { + const transport = mockTransport(); + const widget = mockWidget({ transport } as never); + const bridge = createWidgetHostBridge(widget); + bridge.deviceMute$.subscribe((request) => + request.reply({ audio_enabled: false, video_enabled: true }), + ); + + const ev = askHost(widget, ElementWidgetActions.DeviceMute, { + audio_enabled: false, + }); + + expect(transport.reply).toHaveBeenCalledWith(ev.detail, { + audio_enabled: false, + video_enabled: true, + }); + }); + + test("still replies when there is nothing to say", () => { + const transport = mockTransport(); + const widget = mockWidget({ transport } as never); + const bridge = createWidgetHostBridge(widget); + bridge.hangUp$.subscribe((request) => request.reply()); + + const ev = askHost(widget, ElementWidgetActions.HangupCall, {}); + + // The widget API requires an answer, so an empty reply becomes {} + expect(transport.reply).toHaveBeenCalledWith(ev.detail, {}); + }); + + test("stops listening once unsubscribed", () => { + const widget = mockWidget({ transport: mockTransport() } as never); + const bridge = createWidgetHostBridge(widget); + const seen: unknown[] = []; + const subscription = bridge.hangUp$.subscribe((r) => seen.push(r.data)); + + subscription.unsubscribe(); + askHost(widget, ElementWidgetActions.HangupCall, {}); + + expect(seen).toEqual([]); + }); + }); + describe("downloadMedia", () => { const mxcUri = "mxc://example.org/alice-avatar"; diff --git a/src/analytics/PosthogAnalytics.test.ts b/src/analytics/PosthogAnalytics.test.ts index ba51ba27..cd821c17 100644 --- a/src/analytics/PosthogAnalytics.test.ts +++ b/src/analytics/PosthogAnalytics.test.ts @@ -15,6 +15,7 @@ import { afterAll, } from "vitest"; import posthog, { type CaptureResult } from "posthog-js"; +import { type MatrixClient } from "matrix-js-sdk"; import { Anonymity, @@ -23,6 +24,7 @@ import { } from "./PosthogAnalytics"; import { mockConfig } from "../utils/test"; import { analyticsConfigFromEnvironment } from "../initializer"; +import { optInAnalytics } from "../settings/settings"; describe("PosthogAnalytics", () => { describe("enablement", () => { @@ -281,3 +283,86 @@ describe("PosthogAnalytics", () => { }); }); }); + +describe("identifying the user", () => { + const credentials = { + apiKey: "api_key", + apiHost: "https://api.example.com.localhost", + }; + + function mockClient(accountDataId: string | null): MatrixClient { + return { + isGuest: () => false, + getCrypto: () => undefined, + getAccountDataFromServer: vi + .fn() + .mockResolvedValue( + accountDataId === null ? null : { id: accountDataId }, + ), + setAccountData: vi.fn().mockResolvedValue({}), + } as Partial as MatrixClient; + } + + beforeEach(() => { + PosthogAnalytics.resetInstance(); + optInAnalytics.setValue(true); + }); + + it("reports under the ID its host assigned, and stores nothing", async () => { + const client = mockClient(null); + window.matrixclient = client; + PosthogAnalytics.configure({ + ...credentials, + matrixBackend: "embedded", + hostAnalyticsId: "assigned-by-host", + }); + const identify = vi.spyOn(posthog, "identify"); + + PosthogAnalytics.instance.startListeningToSettingsChanges(); + await vi.waitFor(() => + expect(identify).toHaveBeenCalledWith("assigned-by-host"), + ); + + // The host owns the user's account, so Element Call must not write to it + expect(client.setAccountData).not.toHaveBeenCalled(); + }); + + it("keeps its own ID in account data when it owns the session", async () => { + const client = mockClient(null); + window.matrixclient = client; + PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" }); + + PosthogAnalytics.instance.startListeningToSettingsChanges(); + + // No ID on the server yet, so one is minted and stored for other devices + await vi.waitFor(() => expect(client.setAccountData).toHaveBeenCalled()); + }); + + it("reuses the ID already in account data", async () => { + const client = mockClient("stored-earlier"); + window.matrixclient = client; + PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" }); + const identify = vi.spyOn(posthog, "identify"); + + PosthogAnalytics.instance.startListeningToSettingsChanges(); + await vi.waitFor(() => + expect(identify).toHaveBeenCalledWith("stored-earlier"), + ); + + expect(client.setAccountData).not.toHaveBeenCalled(); + }); + + it("records how it reaches Matrix as a super property", async () => { + window.matrixclient = mockClient("stored-earlier"); + PosthogAnalytics.configure({ ...credentials, matrixBackend: "embedded" }); + const register = vi.spyOn(posthog, "register"); + + PosthogAnalytics.instance.startListeningToSettingsChanges(); + + await vi.waitFor(() => + expect(register).toHaveBeenCalledWith( + expect.objectContaining({ matrixBackend: "embedded" }), + ), + ); + }); +}); diff --git a/src/e2ee/sharedKeyManagement.test.ts b/src/e2ee/sharedKeyManagement.test.ts new file mode 100644 index 00000000..9d037530 --- /dev/null +++ b/src/e2ee/sharedKeyManagement.test.ts @@ -0,0 +1,36 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { afterEach, describe, expect, test } from "vitest"; + +import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement"; + +const roomId = "!room:example.org"; + +describe("getKeyForRoom", () => { + afterEach(() => { + window.location.hash = "#"; + localStorage.clear(); + }); + + test("prefers a key given in the parameters over the stored one", () => { + saveKeyForRoom(roomId, "stored"); + window.location.hash = `#?roomId=${encodeURIComponent(roomId)}&password=from-the-link`; + + expect(getKeyForRoom(roomId)).toBe("from-the-link"); + }); + + test("falls back to the stored key", () => { + saveKeyForRoom(roomId, "stored"); + + expect(getKeyForRoom(roomId)).toBe("stored"); + }); + + test("has no key to offer for a room it has never seen", () => { + expect(getKeyForRoom(roomId)).toBeNull(); + }); +}); diff --git a/src/state/MediaDevices.test.ts b/src/state/MediaDevices.test.ts new file mode 100644 index 00000000..f65b024e --- /dev/null +++ b/src/state/MediaDevices.test.ts @@ -0,0 +1,59 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, test, vi } from "vitest"; +import { of } from "rxjs"; + +const getPlatform = vi.hoisted(() => vi.fn(() => "desktop")); +vi.mock("../Platform", () => ({ + get platform(): string { + return getPlatform(); + }, + isFirefox: (): boolean => false, +})); +vi.mock("@livekit/components-core", () => ({ + createMediaDeviceObserver: () => of([]), +})); + +import { AudioOutput, MediaDevices } from "./MediaDevices"; +import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput"; +import { IOSControlledAudioOutput } from "./IOSControlledAudioOutput"; +import { ObservableScope } from "./ObservableScope"; + +// Which audio output implementation is used is decided by what the app hosting +// Element Call told it, rather than being discovered from the environment. +describe("MediaDevices audio output", () => { + test("uses the browser's own output when nobody else is controlling it", () => { + const devices = new MediaDevices(new ObservableScope(), { + controlledAudioDevices: false, + }); + + expect(devices.audioOutput).toBeInstanceOf(AudioOutput); + }); + + test("hands control to the host on Android", () => { + getPlatform.mockReturnValue("android"); + + const devices = new MediaDevices(new ObservableScope(), { + controlledAudioDevices: true, + callIntent: "audio", + }); + + expect(devices.audioOutput).toBeInstanceOf(AndroidControlledAudioOutput); + }); + + test("hands control to the host elsewhere too", () => { + getPlatform.mockReturnValue("ios"); + + const devices = new MediaDevices(new ObservableScope(), { + controlledAudioDevices: true, + callIntent: "video", + }); + + expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput); + }); +}); diff --git a/src/utils/errors.test.ts b/src/utils/errors.test.ts new file mode 100644 index 00000000..306aa3cf --- /dev/null +++ b/src/utils/errors.test.ts @@ -0,0 +1,62 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, test } from "vitest"; + +import { + ErrorCategory, + ErrorCode, + FailToStartLivekitConnection, + MembershipManagerError, + NoMatrix2AuthorizationService, + SFURoomCreationRestrictedError, +} from "./errors"; + +// These errors take their wording from Element Call's own i18next instance +// rather than the global one, so each needs to come out translated rather than +// as a raw key. +describe("localised errors", () => { + test("MembershipManagerError describes the failure and keeps its cause", () => { + const cause = new Error("the underlying problem"); + const error = new MembershipManagerError(cause); + + expect(error.code).toBe(ErrorCode.INTERNAL_MEMBERSHIP_MANAGER); + expect(error.category).toBe(ErrorCategory.SYSTEM_FAILURE); + expect(error.localisedTitle).not.toContain("error."); + expect(error.localisedMessage).not.toContain("error."); + expect(error.cause).toBe(cause); + }); + + test("NoMatrix2AuthorizationService is a configuration problem", () => { + const cause = new Error("404"); + const error = new NoMatrix2AuthorizationService(cause); + + expect(error.code).toBe(ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE); + expect(error.category).toBe(ErrorCategory.CONFIGURATION_ISSUE); + expect(error.localisedTitle).not.toContain("error."); + expect(error.localisedMessage).not.toContain("error."); + expect(error.cause).toBe(cause); + }); + + test("FailToStartLivekitConnection passes its detail through", () => { + const error = new FailToStartLivekitConnection("could not publish"); + + expect(error.code).toBe(ErrorCode.FAILED_TO_START_LIVEKIT); + expect(error.category).toBe(ErrorCategory.NETWORK_CONNECTIVITY); + expect(error.localisedTitle).not.toContain("error."); + expect(error.localisedMessage).toBe("could not publish"); + }); + + test("SFURoomCreationRestrictedError explains the restriction", () => { + const error = new SFURoomCreationRestrictedError(); + + expect(error.code).toBe(ErrorCode.SFU_ERROR); + expect(error.category).toBe(ErrorCategory.CONFIGURATION_ISSUE); + expect(error.localisedTitle).not.toContain("error."); + expect(error.localisedMessage).not.toContain("error."); + }); +}); From 1bfb018d907e2630eead5226ee5b9613552ad696 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 12:10:04 +0200 Subject: [PATCH 026/154] Introduce ElementCallView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Being in a call and deciding to show one are different jobs, but RoomPage did both: routing, authentication, resolving room aliases and knocking, and then the call itself. Only the first set belongs to whatever is hosting Element Call. Add ElementCallView as the seam between them. It takes a client and a call to join, and owns whether the user has joined — which is the call's own business rather than its host's. RoomPage keeps everything about arriving at a call and renders this for the call itself. Nothing else moves yet. muteStates is still passed in, because the standalone shell shares one with the lobby it shows while waiting to be let into a room, and two instances would both report the user's mute state to the host. --- src/ElementCallView.tsx | 73 +++++++++++++++++++++++++++++++++++++++++ src/room/RoomPage.tsx | 7 ++-- 2 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 src/ElementCallView.tsx diff --git a/src/ElementCallView.tsx b/src/ElementCallView.tsx new file mode 100644 index 00000000..72cf9dda --- /dev/null +++ b/src/ElementCallView.tsx @@ -0,0 +1,73 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { type FC, type ReactNode, useState } from "react"; +import { type MatrixClient } from "matrix-js-sdk"; +import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; + +import { GroupCallView } from "./room/GroupCallView"; +import { type MuteStates } from "./state/MuteStates"; +import { type UrlParams } from "./UrlParams"; + +interface Props { + /** The client to place the call with. */ + client: MatrixClient; + /** The call to join. */ + rtcSession: MatrixRTCSession; + /** The audio and video mute state to start from, and keep in step with. */ + muteStates: MuteStates; + /** + * Whether the user is signed in as a guest, and so should be offered the + * chance to create an account when the call ends. + */ + isPasswordlessUser: boolean; + /** Whether to keep the user in this call rather than letting them navigate. */ + confineToRoom: boolean; + /** Whether to wait for the host to ask us to join. */ + preload: UrlParams["preload"]; + /** Whether to enter the call directly, without showing the lobby first. */ + skipLobby: UrlParams["skipLobby"]; +} + +/** + * A call, as a component. + * + * This owns being in a call, and nothing about how Element Call came to be + * showing one: no routing, no authentication, no resolving of room aliases. + * Those belong to whatever is hosting it — the standalone app's own shell, or + * an application embedding Element Call directly. + * + * TODO: `muteStates` is still passed in, because the standalone shell shares + * one with the lobby it shows while waiting to be let into a room. Ownership + * moves here once that lobby has its own. + */ +export const ElementCallView: FC = ({ + client, + rtcSession, + muteStates, + isPasswordlessUser, + confineToRoom, + preload, + skipLobby, +}): ReactNode => { + // Whether the user is in the call is the call's own business, not its host's. + const [joined, setJoined] = useState(false); + + return ( + + ); +}; diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 33eb80dd..604840e5 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -25,7 +25,7 @@ import { import { useClientLegacy } from "../ClientContext"; import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView"; import { RoomAuthView } from "./RoomAuthView"; -import { GroupCallView } from "./GroupCallView"; +import { ElementCallView } from "../ElementCallView"; import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { HomePage } from "../home/HomePage"; @@ -62,7 +62,6 @@ export const RoomPage: FC = (): ReactNode => { const { avatarUrl, displayName: userDisplayName } = useProfile(client); const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers); - const [joined, setJoined] = useState(false); const devices = useMediaDevices(); const [muteStates, setMuteStates] = useState(null); @@ -125,11 +124,9 @@ export const RoomPage: FC = (): ReactNode => { case "loaded": return ( muteStates && ( - Date: Thu, 3 Sep 2026 12:55:52 +0200 Subject: [PATCH 027/154] Find Element Call's root by attribute, not by being the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six selectors named `body` directly — the gradient backdrop and the platform font overrides in index.css, and the iOS adjustments in AppBar.module.css and Modal.module.css — so they only applied when Element Call owned the page. A host mounting it into a container would have got an interface decorated correctly and styled incorrectly, with nothing to show that anything was wrong. Mark the root element with data-element-call-root and match on that instead. Scoping this way keeps the selectors more specific than they were, rather than less: widening them to a bare [data-platform=…] would have dropped specificity from (0,1,1) to (0,1,0) and changed which rules win. The platform attribute moves with them, from the initializer's write onto document.body to a layout effect on the root, alongside the theme — so it still lands before anything is painted. No visual change while Element Call owns the page: the root is the body, which now carries the attribute, so every rewritten selector matches the element it always did. --- src/AppBar.module.css | 2 +- src/Modal.module.css | 2 +- src/RootElementContext.ts | 19 ++++++++++--------- src/index.css | 8 ++++---- src/initializer.tsx | 4 ---- src/useTheme.test.ts | 15 +++++++++++++++ src/useTheme.ts | 9 +++++++++ 7 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/AppBar.module.css b/src/AppBar.module.css index cc63854e..faf2b0bf 100644 --- a/src/AppBar.module.css +++ b/src/AppBar.module.css @@ -131,7 +131,7 @@ } } -body[data-platform="ios"] { +[data-element-call-root][data-platform="ios"] { .bar > header { grid-template-rows: minmax(var(--cpd-space-11x), auto) var(--cpd-space-4x); grid-template-areas: "primaryButton title secondaryButton"; diff --git a/src/Modal.module.css b/src/Modal.module.css index ae8006a5..30327209 100644 --- a/src/Modal.module.css +++ b/src/Modal.module.css @@ -48,7 +48,7 @@ Please see LICENSE in the repository root for full details. --handle-inset-block-end: var(--cpd-space-4x); } -body[data-platform="ios"] .drawer { +[data-element-call-root][data-platform="ios"] .drawer { --border-radius: 10px; --handle-block-size: 5px; --handle-inline-size: 36px; diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts index d8211c74..48dc2613 100644 --- a/src/RootElementContext.ts +++ b/src/RootElementContext.ts @@ -16,17 +16,18 @@ import { createContext, use } from "react"; * that when embedded in a host application it becomes the container the host * mounted it into, so that Element Call does not reach outside its own subtree. * - * That intent is not yet achievable: several selectors still name `body` - * directly — `body[data-background="gradient"]` and `body[data-platform=…]` in - * `index.css`, and `body[data-platform="ios"]` in `AppBar.module.css` and - * `Modal.module.css` — and `Initializer.initBeforeReact` writes - * `data-platform` straight onto the body. So anything other than the body will - * be decorated correctly and styled incorrectly, silently. Until those are - * scoped, treat this as preparation rather than a working seam. + * The stylesheets find this element by its `data-element-call-root` attribute, + * which {@link useTheme} sets along with the platform and theme, so they no + * longer depend on it being the body. + * + * What remains body-specific is the standalone page's own furniture: the + * `body` rule in `index.css` still sets the page background and margin, and + * `index.html` starts the body hidden with `no-theme` until the theme lands. + * Neither applies when a host mounts Element Call into a container of its own. */ // No provider is exported yet: nothing supplies a root element, so every -// consumer falls back to the document body. M1 adds one along with the -// component that mounts Element Call into a container. +// consumer falls back to the document body. One arrives with the entry point +// that mounts Element Call into a container. const RootElementContext = createContext(null); /** diff --git a/src/index.css b/src/index.css index 77db3394..12f3951d 100644 --- a/src/index.css +++ b/src/index.css @@ -75,7 +75,7 @@ body { } @media (min-height: 330px) { - body[data-background="gradient"]::before { + [data-element-call-root][data-background="gradient"]::before { content: ""; position: fixed; /* Chromium abruptly fades our images to fully transparent at the edge of @@ -88,7 +88,7 @@ body { background-repeat: no-repeat; } - body[data-background="gradient"][data-platform="desktop"]::before { + [data-element-call-root][data-background="gradient"][data-platform="desktop"]::before { background-image: url("graphics/desktop-gradient.png"); background-size: max(1440px, 100vw) max(1440px, 100vh); background-position: center; @@ -126,11 +126,11 @@ body, /* On Android and iOS, prefer native system fonts. The global.css file of Compound Web is where these variables ultimately get consumed to set the page's font-family. */ -body[data-platform="android"] { +[data-element-call-root][data-platform="android"] { --cpd-font-family-sans: "Roboto", "Noto", "Inter", sans-serif; } -body[data-platform="ios"] { +[data-element-call-root][data-platform="ios"] { --cpd-font-family-sans: -apple-system, BlinkMacSystemFont, "Inter", sans-serif; } diff --git a/src/initializer.tsx b/src/initializer.tsx index ee3ee9bd..253dcbc4 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -30,7 +30,6 @@ import { import { getUrlParams } from "./UrlParams"; import { Config } from "./config/Config"; import { seedSettingsFromConfig } from "./settings/settings"; -import { platform } from "./Platform"; import { isFailure } from "./utils/fetch"; import { initializeWidget, type WidgetHelpers } from "./widget"; import { enableExtendedLivekitLogs } from "./settings/settings.ts"; @@ -229,9 +228,6 @@ export class Initializer { ); } - // Add the platform to the DOM, so CSS can query it - document.body.setAttribute("data-platform", platform); - // livekit logging configuration setLKLogExtension((level, msg, context) => { // we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read diff --git a/src/useTheme.test.ts b/src/useTheme.test.ts index e5accd15..fe0cbffb 100644 --- a/src/useTheme.test.ts +++ b/src/useTheme.test.ts @@ -19,6 +19,7 @@ import { } from "vitest"; import { useTheme } from "./useTheme"; +import { platform } from "./Platform"; import { useUrlParams } from "./UrlParams"; import { type HostBridge, @@ -88,6 +89,20 @@ describe("useTheme", () => { expect(originalClassList.add).not.toHaveBeenCalled(); }); + test("marks the element as Element Call's root, for the stylesheets", () => { + renderHook(() => useTheme(), { wrapper }); + + // The stylesheets find the root by this rather than by naming `body`, so + // that they still apply when Element Call is mounted into a container + expect(document.body.hasAttribute("data-element-call-root")).toBe(true); + }); + + test("records the platform on the root, for the stylesheets", () => { + renderHook(() => useTheme(), { wrapper }); + + expect(document.body.getAttribute("data-platform")).toBe(platform); + }); + test("theme changes in response to host requests", () => { renderHook(() => useTheme(), { wrapper }); diff --git a/src/useTheme.ts b/src/useTheme.ts index 3c02745a..86a6f63f 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -9,6 +9,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useUrlParams } from "./UrlParams"; import { useRootElement } from "./RootElementContext"; +import { platform } from "./Platform"; import { useHostBridge } from "./HostBridge"; export const useTheme = (): void => { @@ -28,6 +29,14 @@ export const useTheme = (): void => { return (): void => subscription.unsubscribe(); }, [hostBridge]); + // Mark the element as Element Call's root and record the platform on it, so + // that the stylesheets can find both without naming `body`. A layout effect, + // like the theme below, so that it lands before anything is painted. + useLayoutEffect(() => { + rootElement.setAttribute("data-element-call-root", ""); + rootElement.setAttribute("data-platform", platform); + }, [rootElement]); + useLayoutEffect(() => { // If no theme has been explicitly requested we default to dark const theme = requestedTheme?.includes("light") ? "light" : "dark"; From a166fbbd08384b241e54bc470df8cedc55fe5a59 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 15:15:51 +0200 Subject: [PATCH 028/154] Let the component own its mute state ElementCallView took muteStates as a prop, which would have meant an embedder building one before it could show a call. It could not simply make its own: RoomPage created one on mount whichever branch it went on to render, and two would both report the user's mute state to the host, talking over each other. Move the construction into a useMuteStates hook, and give the lobby shown while waiting to be let into a room its own component. Each lobby now holds mute state only while it is on screen, so there is never a second one, and the component can own the call's. KnockLobbyView also takes the room summary and label handling that RoomPage was assembling on its behalf, leaving the page with arriving at a call rather than being in one. --- src/ElementCallView.tsx | 12 ++-- src/room/KnockLobbyView.tsx | 92 +++++++++++++++++++++++++++++ src/room/RoomPage.tsx | 115 ++++++++---------------------------- src/state/useMuteStates.ts | 51 ++++++++++++++++ 4 files changed, 171 insertions(+), 99 deletions(-) create mode 100644 src/room/KnockLobbyView.tsx create mode 100644 src/state/useMuteStates.ts diff --git a/src/ElementCallView.tsx b/src/ElementCallView.tsx index 72cf9dda..d0b3e4f3 100644 --- a/src/ElementCallView.tsx +++ b/src/ElementCallView.tsx @@ -10,7 +10,7 @@ import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { GroupCallView } from "./room/GroupCallView"; -import { type MuteStates } from "./state/MuteStates"; +import { useMuteStates } from "./state/useMuteStates"; import { type UrlParams } from "./UrlParams"; interface Props { @@ -18,8 +18,6 @@ interface Props { client: MatrixClient; /** The call to join. */ rtcSession: MatrixRTCSession; - /** The audio and video mute state to start from, and keep in step with. */ - muteStates: MuteStates; /** * Whether the user is signed in as a guest, and so should be offered the * chance to create an account when the call ends. @@ -40,15 +38,10 @@ interface Props { * showing one: no routing, no authentication, no resolving of room aliases. * Those belong to whatever is hosting it — the standalone app's own shell, or * an application embedding Element Call directly. - * - * TODO: `muteStates` is still passed in, because the standalone shell shares - * one with the lobby it shows while waiting to be let into a room. Ownership - * moves here once that lobby has its own. */ export const ElementCallView: FC = ({ client, rtcSession, - muteStates, isPasswordlessUser, confineToRoom, preload, @@ -56,6 +49,9 @@ export const ElementCallView: FC = ({ }): ReactNode => { // Whether the user is in the call is the call's own business, not its host's. const [joined, setJoined] = useState(false); + const muteStates = useMuteStates(); + + if (muteStates === null) return null; return ( void) | null; + confineToRoom: boolean; + hideHeader: boolean; +} + +/** + * The lobby shown while the user is outside a room they want to call in — + * either able to ask to join, or waiting for someone to answer. + * + * This belongs to the app shell rather than to the call: it exists precisely + * because there is no call to be in yet. It keeps its own mute state, which is + * why it is a component rather than part of the page — so that the call's mute + * state and this one are never alive at the same time, reporting over each + * other to the host. + */ +export const KnockLobbyView: FC = ({ + client, + roomSummary, + profile, + knock, + confineToRoom, + hideHeader, +}): ReactNode => { + const { t } = useTranslation(); + const muteStates = useMuteStates(); + + if (muteStates === null) return null; + + const waitingForInvite = knock === null; + const enterLabel: string | JSX.Element = waitingForInvite ? ( + <> + {t("lobby.waiting_for_invite")} + + + ) : ( + t("lobby.ask_to_join") + ); + + return ( + knock?.()} + enterLabel={enterLabel} + waitingForInvite={waitingForInvite} + confineToRoom={confineToRoom} + hideHeader={hideHeader} + participantCount={null} + muteStates={muteStates} + onShareClick={null} + /> + ); +}; diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index 604840e5..f49509e3 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -6,21 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - type FC, - useEffect, - useState, - type ReactNode, - useRef, - type JSX, -} from "react"; +import { type FC, useEffect, useState, type ReactNode, useRef } from "react"; import { type MatrixError } from "matrix-js-sdk"; import { logger } from "matrix-js-sdk/lib/logger"; import { Trans, useTranslation } from "react-i18next"; -import { - CheckIcon, - UnknownSolidIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; +import { UnknownSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { useClientLegacy } from "../ClientContext"; import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView"; @@ -29,22 +19,15 @@ import { ElementCallView } from "../ElementCallView"; import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { HomePage } from "../home/HomePage"; -import { useHostBridge } from "../HostBridge.ts"; import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall"; -import { LobbyView } from "./LobbyView"; -import { E2eeType } from "../e2ee/e2eeType"; +import { KnockLobbyView } from "./KnockLobbyView"; import { useProfile } from "../profile/useProfile"; import { useOptInAnalytics } from "../settings/settings"; import { Link } from "../button/Link"; import { ErrorView } from "../ErrorView"; -import { useMediaDevices } from "../MediaDevicesContext"; -import { MuteStates } from "../state/MuteStates"; -import { ObservableScope } from "../state/ObservableScope"; -import { calculateInitialMuteState } from "../state/initialMuteState.ts"; export const RoomPage: FC = (): ReactNode => { const urlParams = useUrlParams(); - const hostBridge = useHostBridge(); const { confineToRoom, preload, header, displayName, skipLobby } = urlParams; const { t } = useTranslation(); const { roomAlias, roomId, viaServers } = useRoomIdentifier(); @@ -63,26 +46,6 @@ export const RoomPage: FC = (): ReactNode => { const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers); - const devices = useMediaDevices(); - const [muteStates, setMuteStates] = useState(null); - - useEffect(() => { - const scope = new ObservableScope(); - setMuteStates( - new MuteStates( - scope, - devices, - calculateInitialMuteState( - urlParams.skipLobby, - urlParams.callIntent, - urlParams.isWidget, - ), - hostBridge, - ), - ); - return (): void => scope.end(); - }, [devices, urlParams, hostBridge]); - useEffect(() => { // If we've finished loading, are not already authed and we've been given a display name as // a URL param, automatically register a passwordless user @@ -123,64 +86,34 @@ export const RoomPage: FC = (): ReactNode => { switch (groupCallState.kind) { case "loaded": return ( - muteStates && ( - - ) + ); case "waitForInvite": case "canKnock": { wasInWaitForInviteState.current = wasInWaitForInviteState.current || groupCallState.kind === "waitForInvite"; - const knock = - groupCallState.kind === "canKnock" ? groupCallState.knock : null; - const label: string | JSX.Element = - groupCallState.kind === "canKnock" ? ( - t("lobby.ask_to_join") - ) : ( - <> - {t("lobby.waiting_for_invite")} - - - ); return ( - muteStates && ( - knock?.()} - enterLabel={label} - waitingForInvite={groupCallState.kind === "waitForInvite"} - confineToRoom={confineToRoom} - hideHeader={header !== "standard"} - participantCount={null} - muteStates={muteStates} - onShareClick={null} - /> - ) + ); } case "loading": diff --git a/src/state/useMuteStates.ts b/src/state/useMuteStates.ts new file mode 100644 index 00000000..ab35169f --- /dev/null +++ b/src/state/useMuteStates.ts @@ -0,0 +1,51 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { useEffect, useState } from "react"; + +import { MuteStates } from "./MuteStates"; +import { ObservableScope } from "./ObservableScope"; +import { calculateInitialMuteState } from "./initialMuteState"; +import { useMediaDevices } from "../MediaDevicesContext"; +import { useHostBridge } from "../HostBridge"; +import { useUrlParams } from "../UrlParams"; + +/** + * Audio and video mute state, kept in step with the host. + * + * `null` until the media devices have been looked at, since what the user + * starts muted depends on what they have. + * + * Whoever shows the user their own camera owns one of these. Note there should + * only ever be one alive at a time: each reports the user's mute state to the + * host, so two would have them talking over each other. + */ +export function useMuteStates(): MuteStates | null { + const urlParams = useUrlParams(); + const hostBridge = useHostBridge(); + const devices = useMediaDevices(); + const [muteStates, setMuteStates] = useState(null); + + useEffect(() => { + const scope = new ObservableScope(); + setMuteStates( + new MuteStates( + scope, + devices, + calculateInitialMuteState( + urlParams.skipLobby, + urlParams.callIntent, + urlParams.isWidget, + ), + hostBridge, + ), + ); + return (): void => scope.end(); + }, [devices, urlParams, hostBridge]); + + return muteStates; +} From 979b5215631b1841fd056e7ec2edf90d0d8eefaf Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 15:18:03 +0200 Subject: [PATCH 029/154] Build Element Call as a component a host can import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds component/index.tsx as a fourth build target: and an initializeElementCall to await once beforehand. It gives Element Call everything it would otherwise take from the page it is on — the parameters, the host bridge, media devices, translations, a container to confine itself to — and hands it the host's client rather than finding one of its own. React, the Matrix SDK and LiveKit stay external, since the host has them and a second copy of any would not merely be wasteful: React would hold two sets of hooks and the client would run two sync loops. Every subpath has to be listed by name, because the pattern and callback forms of rollupOptions.external are silently ignored here — a lesson worth the comment that records it. Element Call's own navigation runs in a MemoryRouter, so being embedded cannot disturb the host's URL. ClientContext and GroupCallView both navigate, so some router has to be present. The bundle is not yet a reasonable size: library mode base64-inlines assets referenced through import.meta.url, so MediaPipe's vision runtime lands in it whole. Left for its own change, since the fix — loading the background blur transformer lazily — is worth doing for the standalone app too. --- component/ElementCall.module.css | 24 ++++ component/index.tsx | 185 +++++++++++++++++++++++++++++++ knip.ts | 7 +- package.json | 3 + pnpm-workspace.yaml | 4 + src/RootElementContext.ts | 9 +- tsconfig.json | 4 +- vite-component.config.ts | 65 +++++++++++ 8 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 component/ElementCall.module.css create mode 100644 component/index.tsx create mode 100644 vite-component.config.ts diff --git a/component/ElementCall.module.css b/component/ElementCall.module.css new file mode 100644 index 00000000..399178ae --- /dev/null +++ b/component/ElementCall.module.css @@ -0,0 +1,24 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +/* The container a host mounts us into. It fills whatever space the host gives +it, and establishes a stacking context of its own so that our overlays and +modals cannot escape it — which is the whole reason for embedding rather than +using an iframe. */ +.root { + display: flex; + flex-direction: column; + inline-size: 100%; + block-size: 100%; + isolation: isolate; + position: relative; + background-color: var(--cpd-color-bg-canvas-default); + color: var(--cpd-color-text-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-tap-highlight-color: transparent; +} diff --git a/component/index.tsx b/component/index.tsx new file mode 100644 index 00000000..e904c8be --- /dev/null +++ b/component/index.tsx @@ -0,0 +1,185 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +/** + * EXPERIMENTAL + * + * Element Call as a React component, for an application that wants to show a + * call inside itself rather than in an iframe. + * + * The host supplies the client and says which room to call in; Element Call + * supplies the call. Everything it would otherwise take from the page it is on + * — the URL, the document body, a Matrix session of its own — comes from the + * host instead, or is confined to the container it is mounted in. + */ + +import { type FC, type JSX, type ReactNode, useMemo, useState } from "react"; +import { type MatrixClient } from "matrix-js-sdk"; +import { logger } from "matrix-js-sdk/lib/logger"; +import { MemoryRouter } from "react-router-dom"; +import { I18nextProvider } from "react-i18next"; +import { TooltipProvider } from "@vector-im/compound-web"; +import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill"; +import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js"; + +import { ElementCallView } from "../src/ElementCallView"; +import { ClientProvider } from "../src/ClientContext"; +import { + type HostBridge, + HostBridgeProvider, + nullHostBridge, +} from "../src/HostBridge"; +import { RootElementProvider } from "../src/RootElementContext"; +import { + computeUrlParams, + type UrlParams, + UrlParamsProvider, +} from "../src/UrlParams"; +import { MediaDevicesContext } from "../src/MediaDevicesContext"; +import { MediaDevices } from "../src/state/MediaDevices"; +import { ObservableScope } from "../src/state/ObservableScope"; +import { ProcessorProvider } from "../src/livekit/TrackProcessorContext"; +import { Config } from "../src/config/Config"; +import { type ConfigOptions } from "../src/config/ConfigOptions"; +import { i18n } from "../src/utils/i18n"; +import { useTheme } from "../src/useTheme"; +import { useInitial } from "../src/useInitial"; +import styles from "./ElementCall.module.css"; + +export { type HostBridge } from "../src/HostBridge"; + +/** + * How Element Call should behave. Everything is optional; anything left out + * takes the same default it would in the standalone app. + */ +export type ElementCallConfiguration = Partial; + +export interface ElementCallProps { + /** + * The client to place the call with. Element Call does not authenticate + * anyone or manage a session of its own; this one is the host's. + */ + client: MatrixClient; + /** The room to call in. The host's client must already know about it. */ + roomId: string; + /** How Element Call should behave. */ + config?: ElementCallConfiguration; + /** + * How to reach the host while the call is running — to be told the user has + * joined or hung up, to be asked to keep the call on screen, and so on. + * Without one, Element Call assumes it has no host to talk to. + */ + hostBridge?: HostBridge; +} + +/** + * Prepares the things Element Call needs before it can be shown: translations, + * `Intl` polyfills for older browsers, and its configuration. + * + * Await this once, before rendering {@link ElementCall}. + */ +export async function initializeElementCall( + config: ConfigOptions = {}, +): Promise { + const polyfills: Promise[] = []; + if (shouldPolyfillSegmenter()) + polyfills.push(import("@formatjs/intl-segmenter/polyfill-force")); + if (shouldPolyfillDurationFormat()) + polyfills.push(import("@formatjs/intl-durationformat/polyfill-force.js")); + await Promise.all(polyfills); + + Config.initWith(config); + await i18n.init({ + fallbackLng: "en", + defaultNS: "app", + keySeparator: ".", + nsSeparator: false, + pluralSeparator: "_", + contextSeparator: "|", + lng: "en", + interpolation: { escapeValue: false }, + }); +} + +/** Applies the theme to the container, before it is painted. */ +const Decoration: FC<{ children: JSX.Element }> = ({ children }) => { + useTheme(); + return children; +}; + +export const ElementCall: FC = ({ + client, + roomId, + config, + hostBridge = nullHostBridge, +}): ReactNode => { + // The container is what Element Call decorates and portals into, so nothing + // inside can render until we have it. + const [container, setContainer] = useState(null); + + // The defaults are the standalone app's, with the host's wishes over the top + const params = useMemo( + (): UrlParams => ({ ...computeUrlParams(), ...config }), + [config], + ); + + const mediaDevices = useInitial( + () => + new MediaDevices(new ObservableScope(), { + controlledAudioDevices: params.controlledAudioDevices, + callIntent: params.callIntent, + }), + ); + + const room = client.getRoom(roomId); + const rtcSession = useMemo( + () => (room === null ? null : client.matrixRTC.getRoomSession(room)), + [client, room], + ); + + if (rtcSession === null) + logger.error( + `Element Call was asked to call in ${roomId}, which its host's client does not know about`, + ); + + return ( + + + + {/* Element Call's own navigation stays in memory, so that being + embedded cannot disturb the host's URL. */} + +
+ {container !== null && rtcSession !== null && ( + + + + + + + + + + + + + + )} +
+
+
+
+
+ ); +}; diff --git a/knip.ts b/knip.ts index 8412d591..97ecc090 100644 --- a/knip.ts +++ b/knip.ts @@ -9,7 +9,12 @@ import { type KnipConfig } from "knip"; export default { vite: { - config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"], + config: [ + "vite.config.ts", + "vite-embedded.config.ts", + "vite-sdk.config.ts", + "vite-component.config.ts", + ], }, entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"], ignoreBinaries: [ diff --git a/package.json b/package.json index 2f4faa60..91ba49de 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "build:sdk:development": "pnpm build:sdk --mode development", "build:sdk": "pnpm build:full --config vite-sdk.config.js", "build:sdk:production": "pnpm build:sdk", + "build:component": "pnpm build:full --config vite-component.config.js", + "build:component:production": "pnpm build:component", + "build:component:development": "pnpm build:component --mode development", "serve": "vite preview", "format": "oxfmt", "format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c32736ca..9e4f0fd6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,7 @@ +supportedArchitectures: + os: [current, linux] + cpu: [current, arm64] + libc: [current, glibc] minimumReleaseAgeExclude: - "@vector-im/compound-design-tokens" - "@vector-im/compound-web" diff --git a/src/RootElementContext.ts b/src/RootElementContext.ts index 48dc2613..906a90d6 100644 --- a/src/RootElementContext.ts +++ b/src/RootElementContext.ts @@ -25,11 +25,14 @@ import { createContext, use } from "react"; * `index.html` starts the body hidden with `no-theme` until the theme lands. * Neither applies when a host mounts Element Call into a container of its own. */ -// No provider is exported yet: nothing supplies a root element, so every -// consumer falls back to the document body. One arrives with the entry point -// that mounts Element Call into a container. const RootElementContext = createContext(null); +/** + * Supplies the element Element Call should confine itself to. The standalone + * and widget builds need no provider, since for them that element is the body. + */ +export const RootElementProvider = RootElementContext.Provider; + /** * The element Element Call should decorate and portal into. * diff --git a/tsconfig.json b/tsconfig.json index 74c27025..aba6c5ec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -53,7 +53,9 @@ "./src/**/*.ts", "./src/**/*.tsx", "./playwright/**/*.ts", - "./sdk/**/*.ts" + "./sdk/**/*.ts", + "./component/**/*.ts", + "./component/**/*.tsx" ], "exclude": ["**.test.ts"] } diff --git a/vite-component.config.ts b/vite-component.config.ts new file mode 100644 index 00000000..1a57abe6 --- /dev/null +++ b/vite-component.config.ts @@ -0,0 +1,65 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { defineConfig } from "vite"; + +import { vitePluginsConfig } from "./vite.config"; + +// Config for Element Call as a React component, to be imported by an +// application embedding it rather than served as a page of its own. +// +// Deliberately not built on top of the full app's config, which exists to +// produce a page and brings an HTML entry point along with it. +export default defineConfig(({ mode }) => ({ + ...vitePluginsConfig({ mode }), + build: { + minify: mode === "production", + sourcemap: true, + // One stylesheet rather than one per chunk, so a host has a single file to + // include + cssCodeSplit: false, + lib: { + formats: ["es" as const], + entry: "./component/index.tsx", + fileName: "element-call", + }, + rollupOptions: { + // The host already has these, and a second copy of any of them does not + // merely bloat the bundle: React would hold two sets of hooks, and the + // Matrix client would run two sync loops. + // + // Every subpath has to be named. Element Call reaches most of the Matrix + // SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not + // catch those — while the pattern and callback forms of this option are + // silently ignored by the bundler, so they cannot be used to cover them. + // `pnpm lint:externals` fails if an import appears that is not listed. + external: [ + "react", + "react/jsx-runtime", + "react-dom", + "react-dom/client", + "livekit-client", + "matrix-js-sdk", + "matrix-js-sdk/lib/client", + "matrix-js-sdk/lib/crypto-api", + "matrix-js-sdk/lib/logger", + "matrix-js-sdk/lib/matrix", + "matrix-js-sdk/lib/matrixrtc", + "matrix-js-sdk/lib/matrixrtc/EncryptionManager", + "matrix-js-sdk/lib/matrixrtc/IKeyTransport", + "matrix-js-sdk/lib/matrixrtc/IMembershipManager", + "matrix-js-sdk/lib/models/relations-container", + "matrix-js-sdk/lib/models/room", + "matrix-js-sdk/lib/models/typed-event-emitter", + "matrix-js-sdk/lib/randomstring", + "matrix-js-sdk/lib/sync", + "matrix-js-sdk/lib/types", + "matrix-js-sdk/lib/utils", + ], + }, + }, +})); From 8060d10fccb62ac2153313d9c877e31106f2278a Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 3 Sep 2026 14:46:49 +0100 Subject: [PATCH 030/154] Don't report an aborted connect as a connection error When the ConnectionManager stops a Connection while livekitRoom.connect() is still pending (which happens on every join, because the local membership emits twice in quick succession and the connection set is recomputed), livekit-client rejects the pending connect with "Client initiated disconnect". Connection.start() then treated that as a failure: it pushed an UnknownCallError into the state of a connection that was already stopped and rethrew, and since start() is fire-and-forget the throw surfaced as Unhandled promise rejection: Error: Failed to connect to Livekit server in every rageshake, right after a "livekitRoom.connect FAILED ... Client initiated disconnect" line. Nothing was actually wrong: the replacement connection connects fine a moment later. Mark the connection as stopped before disconnecting, and have start() return quietly when the rejection is the abort we asked for. --- .../remoteMembers/Connection.test.ts | 28 +++++++++++++++++++ .../CallViewModel/remoteMembers/Connection.ts | 13 ++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/state/CallViewModel/remoteMembers/Connection.test.ts b/src/state/CallViewModel/remoteMembers/Connection.test.ts index 72329585..ae87e98c 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.test.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.test.ts @@ -391,6 +391,34 @@ describe("Start connection states", () => { expect(connectedState).toEqual(ConnectionState.LivekitConnected); }); + it("stopping while connecting does not report an error", async () => { + setupTest(); + const connection = setupRemoteConnection(); + + const capturedStates: (ConnectionState | Error)[] = []; + const s = connection.state$.subscribe((value) => { + capturedStates.push(value); + }); + onTestFinished(() => s.unsubscribe()); + + // livekit-client rejects a pending connect() when disconnect() is called. + const pendingConnect = Promise.withResolvers(); + fakeLivekitRoom.connect.mockReturnValue(pendingConnect.promise); + fakeLivekitRoom.disconnect.mockResolvedValue(undefined); + + const started = connection.start(); + await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled()); + const stopping = connection.stop(); + pendingConnect.reject(new Error("Client initiated disconnect")); + await stopping; + + // start() resolves rather than rejecting (it is not awaited by the + // ConnectionManager, so a rejection would be unhandled). + await expect(started).resolves.toBeUndefined(); + expect(capturedStates.at(-1)).toEqual(ConnectionState.Stopped); + expect(capturedStates.some((st) => st instanceof Error)).toBe(false); + }); + it("shutting down the scope should stop the connection", async () => { setupTest(); vi.useFakeTimers(); diff --git a/src/state/CallViewModel/remoteMembers/Connection.ts b/src/state/CallViewModel/remoteMembers/Connection.ts index f320e630..84ea98ee 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.ts @@ -258,6 +258,15 @@ export class Connection { // If we were stopped while connecting, don't proceed to update state. if (this.stopped) return; } catch (error) { + if (this.stopped) { + // stop() was called while we were connecting, which makes the pending + // connect reject. That is the abort we asked for, not a failure, so + // don't record an error state on a stopped connection or rethrow it + // (start() is not awaited by the ConnectionManager, so a throw here + // becomes an unhandled promise rejection). + this.logger.debug(`Connect aborted because the connection was stopped`); + return; + } this.logger.debug(`Failed to connect to LiveKit room: ${error}`); this._state$.next( error instanceof ElementCallError @@ -296,9 +305,11 @@ export class Connection { `stop: disconnecing from lk room ${this.transport.livekit_service_url}`, ); if (this.stopped) return; + // Mark as stopped before disconnecting so that a connect() aborted by the + // disconnect sees the flag and does not report the abort as an error. + this.stopped = true; await this.livekitRoom.disconnect(); this._state$.next(ConnectionState.Stopped); - this.stopped = true; this.logger.debug( `stop: DONE disconnecing from lk room ${this.transport.livekit_service_url}`, ); From 2e5b5cb186468e8f9eaa48b2ab165b068f82282d Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 3 Sep 2026 14:50:28 +0100 Subject: [PATCH 031/154] Don't attach the background blur processor to an ended camera track If the camera track has already ended by the time the blur processor is applied to it, MediaStreamTrackProcessor cannot be constructed and setProcessor rejects. Both call sites used `void`, so this surfaced as Unhandled promise rejection: TypeError: Failed to construct 'MediaStreamTrackProcessor': Input track cannot be ended (element-call-rageshakes#17225). Skip ended tracks, and catch and log processor attach/detach failures instead of leaking them. --- src/livekit/TrackProcessorContext.test.ts | 62 +++++++++++++++++++++++ src/livekit/TrackProcessorContext.tsx | 44 +++++++++++----- 2 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 src/livekit/TrackProcessorContext.test.ts diff --git a/src/livekit/TrackProcessorContext.test.ts b/src/livekit/TrackProcessorContext.test.ts new file mode 100644 index 00000000..732fa530 --- /dev/null +++ b/src/livekit/TrackProcessorContext.test.ts @@ -0,0 +1,62 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, it, vi } from "vitest"; +import { type LocalVideoTrack } from "livekit-client"; +import { + type BackgroundOptions, + type ProcessorWrapper, +} from "@livekit/track-processors"; + +import { applyProcessor } from "./TrackProcessorContext"; + +const processor = {} as ProcessorWrapper; + +function mockTrack( + readyState: MediaStreamTrackState, + current?: ProcessorWrapper, +): LocalVideoTrack { + return { + mediaStreamTrack: { readyState }, + getProcessor: vi.fn().mockReturnValue(current), + setProcessor: vi.fn().mockResolvedValue(undefined), + stopProcessor: vi.fn().mockResolvedValue(undefined), + } as unknown as LocalVideoTrack; +} + +describe("applyProcessor", () => { + it("attaches the processor to a live track", () => { + const track = mockTrack("live"); + applyProcessor(track, processor); + expect(track.setProcessor).toHaveBeenCalledWith(processor); + }); + + it("does not attach the processor to an ended track", () => { + const track = mockTrack("ended"); + applyProcessor(track, processor); + expect(track.setProcessor).not.toHaveBeenCalled(); + }); + + it("does not surface a rejected setProcessor", async () => { + const track = mockTrack("live"); + vi.mocked(track.setProcessor).mockRejectedValue( + new TypeError("Input track cannot be ended"), + ); + const unhandled = vi.fn(); + process.on("unhandledRejection", unhandled); + applyProcessor(track, processor); + await new Promise((r) => setTimeout(r, 0)); + process.off("unhandledRejection", unhandled); + expect(unhandled).not.toHaveBeenCalled(); + }); + + it("stops the processor when none is wanted", () => { + const track = mockTrack("live", processor); + applyProcessor(track, undefined); + expect(track.stopProcessor).toHaveBeenCalled(); + }); +}); diff --git a/src/livekit/TrackProcessorContext.tsx b/src/livekit/TrackProcessorContext.tsx index 21cd609e..96897929 100644 --- a/src/livekit/TrackProcessorContext.tsx +++ b/src/livekit/TrackProcessorContext.tsx @@ -19,6 +19,7 @@ import { useMemo, } from "react"; import { type LocalVideoTrack } from "livekit-client"; +import { logger } from "matrix-js-sdk/lib/logger"; import { combineLatest, map, type Observable } from "rxjs"; import { useObservable } from "observable-hooks"; @@ -65,6 +66,34 @@ export function useTrackProcessorObservable$(): Observable { return state$; } +/** + * Attaches or detaches the processor so that the track matches the desired + * state, without throwing. + */ +export function applyProcessor( + videoTrack: LocalVideoTrack, + processor: ProcessorWrapper | undefined, +): void { + if (processor && !videoTrack.getProcessor()) { + // A MediaStreamTrackProcessor cannot be constructed on an ended track + // (e.g. the camera was stopped while the processor was being applied), + // and setProcessor rejects with a TypeError. The track is going away + // anyway, so there is nothing to attach to. + if (videoTrack.mediaStreamTrack.readyState === "ended") { + logger.debug("Not attaching video processor to an ended track"); + return; + } + videoTrack.setProcessor(processor).catch((e) => { + logger.warn("Failed to attach video processor", e); + }); + } + if (!processor && videoTrack.getProcessor()) { + videoTrack.stopProcessor().catch((e) => { + logger.warn("Failed to stop video processor", e); + }); + } +} + /** * Updates your video tracks to always use the given processor. */ @@ -78,13 +107,7 @@ export const trackProcessorSync = ( .subscribe(([videoTrack, processorState]) => { if (!processorState) return; if (!videoTrack) return; - const { processor } = processorState; - if (processor && !videoTrack.getProcessor()) { - void videoTrack.setProcessor(processor); - } - if (!processor && videoTrack.getProcessor()) { - void videoTrack.stopProcessor(); - } + applyProcessor(videoTrack, processorState.processor); }); }; @@ -94,12 +117,7 @@ export const useTrackProcessorSync = ( const { processor } = useTrackProcessor(); useEffect(() => { if (!videoTrack) return; - if (processor && !videoTrack.getProcessor()) { - void videoTrack.setProcessor(processor); - } - if (!processor && videoTrack.getProcessor()) { - void videoTrack.stopProcessor(); - } + applyProcessor(videoTrack, processor); }, [processor, videoTrack]); }; From bf5482db8c742a7c6729b167e70b2a5a5d2ca81b Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 3 Sep 2026 14:55:31 +0100 Subject: [PATCH 032/154] Surface screen share failures instead of silently doing nothing toggleScreenSharing only had `.catch(logger.error)`, so a getDisplayMedia request that hangs (element-call-rageshakes#17152: Element Desktop on Windows, the user pressed the screen share button 14 times in 25 seconds and the log shows nothing but the toggle lines and livekit-client's "waiting for pending publication promise timed out") left the user with a button that does nothing and us with no evidence of why. Log when a toggle is requested and when it completes or fails, with the elapsed time, so a hang is visible in the logs. Explicit failures other than the user cancelling the picker show as a non-modal "Could not start screen sharing" toast. Nothing is inferred from a toggle taking a long time: the user may simply be choosing what to share. --- locales/en/app.json | 1 + src/room/InCallView.tsx | 17 ++++++ .../__snapshots__/InCallView.test.tsx.snap | 20 +++---- src/state/CallViewModel/CallViewModel.ts | 7 +++ .../localMember/LocalMember.test.ts | 26 ++++++++++ .../CallViewModel/localMember/LocalMember.ts | 52 +++++++++++++++++-- 6 files changed, 109 insertions(+), 14 deletions(-) diff --git a/locales/en/app.json b/locales/en/app.json index 543942e2..f3d568bb 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -116,6 +116,7 @@ "peer_connection_timeout_description": "Connection to the media server timed out. Try switching to a different network or disabling your VPN. If the problem persists, see our <0>troubleshooting guide or contact your server administrator.", "room_creation_restricted": "Failed to create call", "room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.", + "screen_share_failed": "Could not start screen sharing", "sticky_events_required": "Homeserver does not support Matrix 2.0 calls", "sticky_events_required_description": "This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.", "unexpected_ec_error": "An unexpected error occurred (<0>Error Code: <1>{{ errorCode }}). Please contact your server admin." diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index a57dcce2..3ea88f81 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -270,6 +270,7 @@ export const InCallView: FC = ({ const audioParticipants = useBehavior(vm.livekitRoomItems$); const participantCount = useBehavior(vm.participantCount$); const reconnecting = useBehavior(vm.reconnecting$); + const screenShareError = useBehavior(vm.screenShareError$); const layout = useBehavior(vm.layout$); const edgeToEdge = useBehavior(vm.edgeToEdge$); const overflowing = useBehavior(vm.overflowing$); @@ -399,6 +400,21 @@ export const InCallView: FC = ({ ); } + const onDismissScreenShareToast = useCallback( + () => vm.dismissScreenShareError(), + [vm], + ); + const screenShareToast = ( + + {t("error.screen_share_failed")} + + ); + // The reconnecting toast cannot be dismissed const onDismissReconnectingToast = useCallback(() => {}, []); // We need to use a non-modal toast to avoid trapping focus within the toast. @@ -634,6 +650,7 @@ export const InCallView: FC = ({ {reconnectingToast} + {screenShareToast} {earpieceOverlay} {footer} diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 984e2b12..7cd044bf 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -171,7 +171,7 @@ exports[`InCallView > rendering > renders 1`] = ` class="_settingsLogoContainer_20b7b4" > + + + + +
+ {/* Resizable, because how Element Call copes with the size it is given is + one of the things we cannot find out from the standalone app */} +
+ {mounted && ( + + )} +
+ + ); +}; + +/** Host furniture, to make it visible if Element Call styles anything but itself. */ +const HostChrome: FC = (): ReactNode => ( + +); + +/** + * A dialog of the host's own, over the top of the calls. Element Call embedded + * in a host has to sit underneath this — being unable to is one of the reasons + * for embedding it rather than putting it in an iframe. + */ +const HostDialog: FC<{ onClose: () => void }> = ({ onClose }): ReactNode => ( +
+
+

A dialog belonging to the host

+

This should cover the calls completely.

+ +
+
+); + +/** + * Stands in for a host application embedding Element Call: it owns the Matrix + * clients, the page and the space each call is given, and reaches Element Call + * only through the component's public interface. + * + * Two calls at once, from two devices of the same account, so that a real call + * happens between them and anything Element Call keeps once per process rather + * than once per call shows itself. + */ +export const Harness: FC = (): ReactNode => { + const [credentials, setCredentials] = useState(loadCredentials); + const [state, setState] = useState({ phase: "credentials" }); + const [entries, setEntries] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + + const log = useCallback((pane: string, message: string): void => { + setEntries((entries) => + [ + ...entries, + { pane, message, at: new Date().toLocaleTimeString() }, + ].slice(-100), + ); + }, []); + + const start = useCallback( + (event: FormEvent): void => { + event.preventDefault(); + localStorage.setItem(CREDENTIALS_KEY, JSON.stringify(credentials)); + const { homeserver, username, password, room } = credentials; + + const progress = (message: string): void => + setState({ phase: "starting", progress: message }); + progress("Starting"); + + void (async (): Promise => { + try { + // One at a time: two logins at once from the same account is the + // shape of request homeservers rate limit + const sessions: Session[] = []; + for (const label of ["Call A", "Call B"]) + sessions.push({ + label, + client: await createSession( + homeserver, + username, + password, + (message) => progress(`${label}: ${message}`), + ), + }); + + progress("Joining the room"); + let roomId = room; + for (const { client } of sessions) + roomId = await joinRoom(client, roomId); + + setState({ phase: "started", roomId, sessions }); + } catch (e) { + logger.error("The harness could not start", e); + setState({ phase: "failed", error: `${e}` }); + } + })(); + }, + [credentials], + ); + + const field = ( + name: keyof Credentials, + label: string, + type = "text", + ): ReactNode => ( + + ); + + if (state.phase !== "started") + return ( +
+

Element Call component harness

+

+ Signs in twice and shows Element Call embedded twice, in a page that + is not Element Call's own. +

+ {field("homeserver", "Homeserver")} + {field("username", "Username")} + {field("password", "Password", "password")} + {field("room", "Room ID or alias")} + + {state.phase === "starting" &&

{state.progress}

} + {state.phase === "failed" && ( +

{state.error}

+ )} +
+ ); + + return ( +
+
+

Element Call component harness

+ {state.roomId} + +
+
+ +
+ {state.sessions.map((session) => ( + + ))} +
+
+
+

Host bridge

+
    + {entries.map((entry, i) => ( +
  1. + {entry.at} {entry.pane}{" "} + {entry.message} +
  2. + ))} +
+
+ {dialogOpen && setDialogOpen(false)} />} +
+ ); +}; diff --git a/component/dev/host.css b/component/dev/host.css new file mode 100644 index 00000000..76e70f60 --- /dev/null +++ b/component/dev/host.css @@ -0,0 +1,23 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +/* The host page's own styles. Deliberately plain, and deliberately not using +Element Call's design tokens: the harness should look like it does because of +this file, not because Element Call styled it. */ + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + font-family: system-ui, sans-serif; + background-color: #f4f4f5; + color: #18181b; +} diff --git a/component/dev/index.html b/component/dev/index.html new file mode 100644 index 00000000..7174e935 --- /dev/null +++ b/component/dev/index.html @@ -0,0 +1,21 @@ + + + + + + + Element Call component harness + + + +
+ + + diff --git a/component/dev/main.tsx b/component/dev/main.tsx new file mode 100644 index 00000000..9150678d --- /dev/null +++ b/component/dev/main.tsx @@ -0,0 +1,42 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { type ConfigOptions, initializeElementCall } from "../index"; +import { Harness } from "./Harness"; +// After Element Call's, so that the host has the last word on its own page +import "./host.css"; + +/** + * The development app's own `config.json`, so that the harness runs Element + * Call the way `pnpm dev` does. It is not in the repository — developers copy + * it from `config/config.devenv.json` — so its absence is expected rather than + * an error. + */ +async function loadConfig(): Promise { + try { + const response = await fetch("/config.json"); + if (response.ok) return (await response.json()) as ConfigOptions; + logger.warn( + `No config.json (${response.status}); running with Element Call's defaults`, + ); + } catch (e) { + logger.warn("Could not read config.json", e); + } + return {}; +} + +await initializeElementCall(await loadConfig()); + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/component/dev/session.ts b/component/dev/session.ts new file mode 100644 index 00000000..5a424a40 --- /dev/null +++ b/component/dev/session.ts @@ -0,0 +1,79 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + ClientEvent, + createClient, + type MatrixClient, + MemoryStore, + SyncState, +} from "matrix-js-sdk"; + +/** + * Logs in and brings up a client the way a host application would, so that the + * component is handed a real one rather than something Element Call built for + * itself. + * + * Everything is kept in memory and a fresh login happens on every reload. That + * costs a device on the development homeserver each time, which is harmless, + * and buys the harness two clients that cannot tread on each other's storage. + * Persisting the login to make reloads quicker would mean persisting the + * crypto store too: reusing a device ID with a fresh crypto store generates new + * device keys, and uploading them conflicts with the ones the server already + * holds. + */ +export async function createSession( + homeserver: string, + username: string, + password: string, + onProgress: (message: string) => void, +): Promise { + onProgress("Logging in"); + const login = await createClient({ baseUrl: homeserver }).login( + "m.login.password", + { identifier: { type: "m.id.user", user: username }, password }, + ); + + const client = createClient({ + baseUrl: homeserver, + accessToken: login.access_token, + userId: login.user_id, + deviceId: login.device_id, + store: new MemoryStore(), + useAuthorizationHeader: true, + fallbackICEServerAllowed: true, + }); + + onProgress(`Setting up crypto for ${login.device_id}`); + await client.initRustCrypto({ useIndexedDB: false }); + + onProgress(`Syncing ${login.device_id}`); + await client.startClient(); + await new Promise((resolve) => { + const onSync = (state: SyncState): void => { + if (state !== SyncState.Prepared && state !== SyncState.Syncing) return; + client.off(ClientEvent.Sync, onSync); + resolve(); + }; + client.on(ClientEvent.Sync, onSync); + }); + + return client; +} + +/** + * The room to call in, joining it if this session is not in it yet — a host + * hands Element Call a room it already knows about, so the harness has to get + * itself into that position first. + */ +export async function joinRoom( + client: MatrixClient, + roomIdOrAlias: string, +): Promise { + const room = await client.joinRoom(roomIdOrAlias); + return room.roomId; +} diff --git a/knip.ts b/knip.ts index 97ecc090..d9de80c8 100644 --- a/knip.ts +++ b/knip.ts @@ -14,6 +14,7 @@ export default { "vite-embedded.config.ts", "vite-sdk.config.ts", "vite-component.config.ts", + "vite-component-dev.config.ts", ], }, entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"], diff --git a/package.json b/package.json index 91ba49de..cf1e1f71 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "pnpm dev:full", "dev:full": "vite", "dev:embedded": "vite --config vite-embedded.config.js", + "dev:component": "vite --config vite-component-dev.config.ts", "build": "pnpm build:full", "build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build", "build:full:production": "pnpm build:full", @@ -22,10 +23,11 @@ "serve": "vite preview", "format": "oxfmt", "format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc", - "lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip", - "lint:oxlint": "oxlint src playwright", - "lint:oxlint-fix": "oxlint --fix src playwright", + "lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals", + "lint:oxlint": "oxlint src component playwright", + "lint:oxlint-fix": "oxlint --fix src component playwright", "lint:knip": "knip", + "lint:externals": "node scripts/check-component-externals.mjs", "lint:types": "tsc", "i18n": "npx i18next-cli extract", "i18n:check": "npx i18next-cli extract --ci", diff --git a/scripts/check-component-externals.mjs b/scripts/check-component-externals.mjs new file mode 100644 index 00000000..ee7d57da --- /dev/null +++ b/scripts/check-component-externals.mjs @@ -0,0 +1,140 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +/** + * Checks that the component build leaves the packages a host must supply to + * the host. + * + * A host application already has React, the Matrix SDK and LiveKit, and a + * second copy of any of them is worse than dead weight: React would hold two + * sets of hooks, and the Matrix client would run two sync loops. So the + * component build lists them as external — but that list has to name every + * subpath, since the bundler silently ignores the pattern and callback forms + * of the option, and an import it does not cover is bundled with no warning at + * all. That is the failure this guards against. + * + * It reads the list from the build config itself, so there is one copy of it, + * and compares it against every import of those packages in the source. + * + * The comparison is deliberately over-approximate: it looks at all of `src` + * rather than only the modules the component actually pulls in, so it will + * sometimes ask for a subpath that only the standalone app imports. Listing + * one the component never imports costs nothing — the bundler ignores it — + * whereas missing one costs a duplicate package. + */ + +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { loadConfigFromFile } from "vite"; + +const CONFIG = "vite-component.config.ts"; +const SOURCES = ["src", "component"]; + +/** The packages whose duplication would break a host, rather than merely enlarge it. */ +const MUST_BE_EXTERNAL = [ + "react", + "react-dom", + "matrix-js-sdk", + "livekit-client", +]; + +const isTestFile = (name) => + name.includes(".test.") || name.includes(".stories."); + +/** Every source file under the given directories, recursively. */ +async function* sourceFiles(dir) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* sourceFiles(path); + else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name)) + yield path; + } +} + +/** + * The module specifiers a source file imports. Covers `from "…"` (which is + * both static imports and re-exports), bare `import "…"` for side effects, and + * dynamic `import("…")`. + */ +function imports(source) { + const specifiers = []; + for (const pattern of [ + /\bfrom\s*["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /^\s*import\s+["']([^"']+)["']/gm, + ]) + for (const [, specifier] of source.matchAll(pattern)) + specifiers.push(specifier); + return specifiers; +} + +/** + * Whether a specifier is an import of one of the packages we care about. + * + * Imports carrying a resource query — `?worker`, `?inline` and friends — are + * not, whatever package they name. Those ask the bundler for a script to run + * in a context of its own, which has to be self-contained and shares no state + * with the host's copy of anything. Worker sub-builds do not inherit this + * option anyway. + */ +const mustBeExternal = (specifier) => + !specifier.includes("?") && + MUST_BE_EXTERNAL.some( + (pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`), + ); + +const loaded = await loadConfigFromFile( + { command: "build", mode: "production" }, + CONFIG, +); +if (loaded === null) { + console.error(`Could not load ${CONFIG}`); + process.exit(1); +} +const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []); +if (declared.size === 0) { + console.error( + `${CONFIG} declares nothing external. Either the option moved, or the ` + + `list is empty; either way this check is not looking at what it thinks.`, + ); + process.exit(1); +} + +// Where each missing specifier is imported, so the message can point at it +const missing = new Map(); +const seen = new Set(); +for (const dir of SOURCES) + for await (const file of sourceFiles(dir)) { + const source = await readFile(file, "utf8"); + for (const specifier of imports(source)) { + if (!mustBeExternal(specifier)) continue; + seen.add(specifier); + if (declared.has(specifier)) continue; + const files = missing.get(specifier) ?? []; + files.push(file); + missing.set(specifier, files); + } + } + +if (missing.size > 0) { + console.error( + `${CONFIG} does not declare these imports external, so the component ` + + `build would bundle its own copy of them:\n`, + ); + for (const [specifier, files] of [...missing].sort()) + console.error(` ${specifier}\n imported by ${files.join(", ")}`); + console.error(`\nAdd each one to the \`external\` list in ${CONFIG}.`); + process.exit(1); +} + +// Deliberately no complaint about declarations nothing imports. Some of them +// cannot be seen from the source at all — `react/jsx-runtime` is injected by +// the JSX transform — and an extra declaration is inert, so there is nothing +// to warn about. +console.log( + `${declared.size} external declarations cover all ${seen.size} imports of ${MUST_BE_EXTERNAL.join(", ")}.`, +); diff --git a/vite-component-dev.config.ts b/vite-component-dev.config.ts new file mode 100644 index 00000000..61cb5e51 --- /dev/null +++ b/vite-component-dev.config.ts @@ -0,0 +1,77 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { defineConfig, searchForWorkspaceRoot } from "vite"; +import { realpathSync } from "node:fs"; +import * as fs from "node:fs"; + +import { vitePluginsConfig } from "./vite.config"; + +// Serves the harness under `component/dev`, which embeds Element Call as a +// component the way a host application would. Development only: this is not +// something we build or ship. +// +// It shares the plugins with the standalone app but not its HTML entry point, +// since the harness has a page of its own — and it deliberately does not build +// on the app's config, which would also bring the app's build output along. +export default defineConfig(({ mode }) => { + // The crypto WASM module is imported dynamically, so Vite has to be told + // that reading it is legitimate — including from a linked copy, which is why + // the paths are resolved rather than assumed. Same as the standalone app. + const allow = [searchForWorkspaceRoot(process.cwd())]; + for (const path of [ + "node_modules/matrix-js-sdk/node_modules/@matrix-org/matrix-sdk-crypto-wasm", + "node_modules/@matrix-org/matrix-sdk-crypto-wasm", + ]) { + try { + allow.push(realpathSync(path)); + } catch {} + } + + return { + ...vitePluginsConfig({ mode, html: false }), + root: "component/dev", + // So that the harness can read the same config.json the standalone app + // does, if the developer has written one + publicDir: "../../public", + server: { + host: true, + // One up from the standalone app's, so both can run at once — the point + // of the harness is to compare them + port: 3001, + fs: { allow }, + // The same certificate the app uses, so that the harness is served from + // a `m.localhost` name the development homeserver's certificate covers + https: { + key: fs.readFileSync("./backend/dev_tls_m.localhost.key"), + cert: fs.readFileSync("./backend/dev_tls_m.localhost.crt"), + }, + }, + worker: { + format: "es", + }, + resolve: { + alias: { + // matrix-widget-api has its transpiled lib/index.js as its entry point, + // which Vite for some reason refuses to work with, so we point it to + // src/index.ts instead + "matrix-widget-api": "matrix-widget-api/src/index.ts", + }, + dedupe: [ + "react", + "react-dom", + "matrix-js-sdk", + "react-use-measure", + // These packages modify the document based on some module-level global + // state, and don't play nicely with duplicate copies of themselves + // https://github.com/radix-ui/primitives/issues/1241#issuecomment-1847837850 + "@radix-ui/react-focus-guards", + "@radix-ui/react-dismissable-layer", + ], + }, + }; +}); diff --git a/vite-component.config.ts b/vite-component.config.ts index 1a57abe6..17f62562 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -15,7 +15,11 @@ import { vitePluginsConfig } from "./vite.config"; // Deliberately not built on top of the full app's config, which exists to // produce a page and brings an HTML entry point along with it. export default defineConfig(({ mode }) => ({ - ...vitePluginsConfig({ mode }), + ...vitePluginsConfig({ mode, html: false }), + // A library has no public directory to serve. Without this the build copies + // whatever is in `public` — including the developer's own config.json, which + // is not in the repository — into the output we would publish. + publicDir: false, build: { minify: mode === "production", sourcemap: true, @@ -36,7 +40,10 @@ export default defineConfig(({ mode }) => ({ // SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not // catch those — while the pattern and callback forms of this option are // silently ignored by the bundler, so they cannot be used to cover them. - // `pnpm lint:externals` fails if an import appears that is not listed. + // `pnpm lint:externals` reads this list and fails if the source imports + // one of these packages by a path it does not name; a few entries below + // are there only because the standalone app imports them, which costs + // nothing. external: [ "react", "react/jsx-runtime", @@ -44,8 +51,10 @@ export default defineConfig(({ mode }) => ({ "react-dom/client", "livekit-client", "matrix-js-sdk", + "matrix-js-sdk/lib/browser-index", "matrix-js-sdk/lib/client", "matrix-js-sdk/lib/crypto-api", + "matrix-js-sdk/lib/indexeddb-worker", "matrix-js-sdk/lib/logger", "matrix-js-sdk/lib/matrix", "matrix-js-sdk/lib/matrixrtc", diff --git a/vite.config.ts b/vite.config.ts index 61936cfd..678a86fa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,7 +26,15 @@ import * as fs from "node:fs"; export const vitePluginsConfig = ({ mode, -}: Pick): UserConfig => { + html = true, +}: Pick & { + /** + * Whether to inject Element Call's entry point into the HTML page. Builds + * that produce a library, or serve a page of their own, must not have this: + * it would pull the standalone app in alongside whatever they are building. + */ + html?: boolean; +}): UserConfig => { const env = loadEnv(mode, process.cwd()); const plugins: PluginOption[] = [ babel({ @@ -67,7 +75,7 @@ export const vitePluginsConfig = ({ ); } - if (!process.env.STORYBOOK && !process.env.VITEST) { + if (html && !process.env.STORYBOOK && !process.env.VITEST) { plugins.push( createHtmlPlugin({ entry: "src/main.tsx", From c0a54dcaf938c4e28dbe231ebc8e743d0caed32a Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 16:39:14 +0200 Subject: [PATCH 035/154] Derive the component's defaults from an intent, not the host's URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component seeded its parameters with `computeUrlParams()`, which reads `window.location`. Element Call is not the page any more, so what it found there was the host's URL: no `widgetId`, therefore not a widget, therefore no intent, therefore the standalone app's preset. The visible symptom was the Element Call logo in the footer of a call embedded in someone else's application, since `showLogo` follows `header === HeaderStyle.Standard`. The rest of that preset mattered more. A hosted call defaulted to `perParticipantE2EE: false` and `confineToRoom: false` — so unencrypted, and willing to take the user out of the room a host had put them in — and a host could not correct either without naming every parameter itself, nor even name `header`, since the enums were not exported. So the intent presets move out of `computeUrlParams` into `configurationForIntent`, and the component builds its parameters from an intent plus the properties a host has no URL to supply. `intent` becomes a prop, defaulting to joining an existing group call: the lobby first, confined to the room, encrypted per participant, and no Element Call branding in someone else's interface. A host that knows which button the user pressed should say which. One deliberate difference from the widget presets: the background defaults to solid rather than the gradient, which is drawn by a `position: fixed` pseudo-element and would escape the container to cover the host. `UserIntent.Unknown` still means the standalone preset, so the app and widget are unchanged — including a widget that names no intent, which has always been given those defaults. --- component/index.tsx | 43 +++++++-- src/UrlParams.test.ts | 48 ++++++++++ src/UrlParams.ts | 202 +++++++++++++++++++++++++++--------------- 3 files changed, 215 insertions(+), 78 deletions(-) diff --git a/component/index.tsx b/component/index.tsx index 1d3155d3..55e2f996 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -48,9 +48,11 @@ import { } from "../src/HostBridge"; import { RootElementProvider } from "../src/RootElementContext"; import { - computeUrlParams, + configurationForIntent, + hostedProperties, type UrlParams, UrlParamsProvider, + UserIntent, } from "../src/UrlParams"; import { MediaDevicesContext } from "../src/MediaDevicesContext"; import { MediaDevices } from "../src/state/MediaDevices"; @@ -74,10 +76,17 @@ export { type JoinCallData } from "../src/widget"; // The deployment-wide configuration, as distinct from ElementCallConfiguration // above, which is per call export { type ConfigOptions } from "../src/config/ConfigOptions"; +// The values that appear in ElementCallConfiguration and in the intent +export { + BackgroundStyle, + HeaderStyle, + UserIntent, + type UrlConfiguration, +} from "../src/UrlParams"; /** * How Element Call should behave. Everything is optional; anything left out - * takes the same default it would in the standalone app. + * takes the default that {@link ElementCallProps.intent} implies. */ export type ElementCallConfiguration = Partial; @@ -89,7 +98,21 @@ export interface ElementCallProps { client: MatrixClient; /** The room to call in. The host's client must already know about it. */ roomId: string; - /** How Element Call should behave. */ + /** + * What the user asked for — whether they started the call or joined one that + * was already running, and whether it is a call in a group or a DM. Element + * Call decides what each of those means: whether to show the lobby first, + * whether to ring, and so on. + * + * Defaults to joining an existing group call, which is the most conservative + * reading, but a host that knows which button the user pressed should say so. + */ + intent?: UserIntent; + /** + * How Element Call should behave, overriding whatever {@link intent} implies. + * A host that finds itself setting a lot of these probably wants a different + * intent instead. + */ config?: ElementCallConfiguration; /** * How to reach the host while the call is running — to be told the user has @@ -143,6 +166,7 @@ const Decoration: FC<{ children: JSX.Element }> = ({ children }) => { export const ElementCall: FC = ({ client, roomId, + intent = UserIntent.JoinExistingCall, config, hostBridge = nullHostBridge, }): ReactNode => { @@ -150,10 +174,17 @@ export const ElementCall: FC = ({ // inside can render until we have it. const [container, setContainer] = useState(null); - // The defaults are the standalone app's, with the host's wishes over the top + // Element Call has no URL of its own to read any of this from, and the + // host's URL is not Element Call's business, so the defaults come from the + // intent with the host's wishes over the top. const params = useMemo( - (): UrlParams => ({ ...computeUrlParams(), ...config }), - [config], + (): UrlParams => ({ + ...hostedProperties, + roomId, + ...configurationForIntent(intent), + ...config, + }), + [roomId, intent, config], ); const mediaDevices = useInitial( diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index 3a61a76b..63c4e2c8 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -11,10 +11,14 @@ import { logger } from "matrix-js-sdk/lib/logger"; import * as PlatformMod from "../src/Platform"; import { + BackgroundStyle, + configurationForIntent, getRoomIdentifierFromUrl, computeUrlParams, HeaderStyle, getUrlParams, + hostedProperties, + UserIntent, } from "../src/UrlParams"; import { mockConfig } from "./utils/test"; @@ -424,4 +428,48 @@ describe("UrlParams", () => { ); }); }); + + // What Element Call runs with when a host embeds it as a component, which + // has no URL of its own for any of this to come from + describe("hosted defaults", () => { + it("assume nothing about a session or a page", () => { + expect(hostedProperties).toMatchObject({ + // The host is not a widget host, and supplies the client itself, so + // none of the widget or session plumbing applies + isWidget: false, + widgetId: null, + parentUrl: null, + userId: null, + deviceId: null, + baseUrl: null, + homeserver: null, + // The gradient is drawn by a `position: fixed` pseudo-element, which + // would escape the container and cover the host's own interface + background: BackgroundStyle.Solid, + }); + }); + + it("keep a hosted call inside its room", () => { + const hosted = configurationForIntent(UserIntent.JoinExistingCall); + expect(hosted).toMatchObject({ + // A host owns navigation, so Element Call must not offer a way out of + // the room + confineToRoom: true, + perParticipantE2EE: true, + // The lobby first, so that the user picks their devices rather than + // being thrown into the call by the act of being rendered + skipLobby: false, + }); + // No Element Call branding inside someone else's application + expect(hosted.header).not.toBe(HeaderStyle.Standard); + }); + + it("fall back to the standalone app's when no intent is stated", () => { + expect(configurationForIntent(UserIntent.Unknown)).toMatchObject({ + confineToRoom: false, + header: HeaderStyle.Standard, + perParticipantE2EE: false, + }); + }); + }); }); diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 422163fc..25b60dbd 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -354,6 +354,135 @@ export const getUrlParams = ( return params; }; +/** + * The configuration implied by what the user meant to do — if they pressed a + * Start Call button this would be `start_call`, and if they pressed Join Call, + * `join_existing`. + * + * These are platform-specific defaults, so that a host can start a call by + * saying what the user asked for rather than by setting every parameter itself, + * and so that what each intent means is Element Call's decision, made in one + * place. A host that wants something else states it alongside the intent. + * + * {@link UserIntent.Unknown} means no intent was stated, and gives the + * standalone app's defaults: Element Call owns the whole page, so it offers the + * way out of the room that a hosted call must not. + */ +export function configurationForIntent(intent: UserIntent): UrlConfiguration { + // Only constants and `platform` here, so that this depends on nothing but + // the intent. + let preset: UrlConfiguration = { + confineToRoom: true, + preload: false, + header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, + showControls: true, + hideScreensharing: false, + allowIceFallback: true, + perParticipantE2EE: true, + controlledAudioDevices: platform === "desktop" ? false : true, + skipLobby: true, + returnToLobby: false, + sendNotificationType: "notification", + autoLeaveWhenOthersLeft: false, + waitForCallPickup: false, + }; + switch (intent) { + case UserIntent.StartNewCall: + preset.skipLobby = false; + preset.callIntent = "video"; + break; + case UserIntent.JoinExistingCall: + // On desktop this will be overridden based on which button was used to join the call + preset.skipLobby = false; + preset.callIntent = "video"; + break; + case UserIntent.StartNewCallVoice: + preset.skipLobby = false; + preset.callIntent = "audio"; + break; + case UserIntent.JoinExistingCallVoice: + // On desktop this will be overridden based on which button was used to join the call + preset.skipLobby = false; + preset.callIntent = "audio"; + break; + case UserIntent.StartNewCallDMVoice: + preset.callIntent = "audio"; + // Fall through + case UserIntent.StartNewCallDM: + preset.skipLobby = true; + preset.sendNotificationType = "ring"; + preset.autoLeaveWhenOthersLeft = true; + preset.waitForCallPickup = true; + preset.callIntent = preset.callIntent ?? "video"; + break; + case UserIntent.JoinExistingCallDMVoice: + preset.callIntent = "audio"; + // Fall through + case UserIntent.JoinExistingCallDM: + // On desktop this will be overridden based on which button was used to join the call + preset.skipLobby = true; + preset.autoLeaveWhenOthersLeft = true; + preset.callIntent = preset.callIntent ?? "video"; + break; + // Non widget usecase defaults + default: + preset = { + confineToRoom: false, + preload: false, + header: HeaderStyle.Standard, + showControls: true, + hideScreensharing: false, + allowIceFallback: false, + perParticipantE2EE: false, + controlledAudioDevices: false, + skipLobby: false, + returnToLobby: false, + sendNotificationType: undefined, + autoLeaveWhenOthersLeft: false, + waitForCallPickup: false, + }; + } + return preset; +} + +/** + * The {@link UrlProperties} for Element Call embedded in a host application. + * + * It has no URL of its own to read these from, and it does not need most of + * them: the widget plumbing does not apply, the Matrix client and the analytics + * configuration come from the host by other routes, and what is left is either + * the host's to state through the component's props or Element Call's own + * default. + */ +export const hostedProperties: UrlProperties = { + widgetId: null, + parentUrl: null, + isWidget: false, + roomId: null, + userId: null, + displayName: null, + deviceId: null, + baseUrl: null, + lang: null, + fonts: [], + fontScale: null, + posthogUserId: null, + posthogApiHost: null, + posthogApiKey: null, + e2eEnabled: true, + password: null, + viaServers: null, + homeserver: null, + rageshakeSubmitUrl: null, + sentryDsn: null, + sentryEnvironment: null, + theme: null, + // Solid rather than the gradient the standalone app defaults to: the gradient + // is drawn by a `position: fixed` pseudo-element, which would escape the + // container Element Call was given and cover the host's own interface. + background: BackgroundStyle.Solid, +}; + /** * Gets the app parameters for the current URL. * @param search The URL search string @@ -383,78 +512,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { const intent = !isWidget ? UserIntent.Unknown : (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown); - // Here we only use constants and `platform` to determine the intent preset. - let intentPreset: UrlConfiguration = { - confineToRoom: true, - preload: false, - header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar, - showControls: true, - hideScreensharing: false, - allowIceFallback: true, - perParticipantE2EE: true, - controlledAudioDevices: platform === "desktop" ? false : true, - skipLobby: true, - returnToLobby: false, - sendNotificationType: "notification", - autoLeaveWhenOthersLeft: false, - waitForCallPickup: false, - }; - switch (intent) { - case UserIntent.StartNewCall: - intentPreset.skipLobby = false; - intentPreset.callIntent = "video"; - break; - case UserIntent.JoinExistingCall: - // On desktop this will be overridden based on which button was used to join the call - intentPreset.skipLobby = false; - intentPreset.callIntent = "video"; - break; - case UserIntent.StartNewCallVoice: - intentPreset.skipLobby = false; - intentPreset.callIntent = "audio"; - break; - case UserIntent.JoinExistingCallVoice: - // On desktop this will be overridden based on which button was used to join the call - intentPreset.skipLobby = false; - intentPreset.callIntent = "audio"; - break; - case UserIntent.StartNewCallDMVoice: - intentPreset.callIntent = "audio"; - // Fall through - case UserIntent.StartNewCallDM: - intentPreset.skipLobby = true; - intentPreset.sendNotificationType = "ring"; - intentPreset.autoLeaveWhenOthersLeft = true; - intentPreset.waitForCallPickup = true; - intentPreset.callIntent = intentPreset.callIntent ?? "video"; - break; - case UserIntent.JoinExistingCallDMVoice: - intentPreset.callIntent = "audio"; - // Fall through - case UserIntent.JoinExistingCallDM: - // On desktop this will be overridden based on which button was used to join the call - intentPreset.skipLobby = true; - intentPreset.autoLeaveWhenOthersLeft = true; - intentPreset.callIntent = intentPreset.callIntent ?? "video"; - break; - // Non widget usecase defaults - default: - intentPreset = { - confineToRoom: false, - preload: false, - header: HeaderStyle.Standard, - showControls: true, - hideScreensharing: false, - allowIceFallback: false, - perParticipantE2EE: false, - controlledAudioDevices: false, - skipLobby: false, - returnToLobby: false, - sendNotificationType: undefined, - autoLeaveWhenOthersLeft: false, - waitForCallPickup: false, - }; - } + const intentPreset = configurationForIntent(intent); const properties: UrlProperties = { widgetId, From 3361ce2b609b70b473e3d6cf811866ef1d5d5840 Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 17:30:16 +0200 Subject: [PATCH 036/154] Keep what the component draws inside the container it was given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening settings in an embedded call put the dialog in the middle of the host's window, spilling outside the container, and with two calls on a page the second drew over the first's dialog. The container established a stacking context but not a containing block, which are two different things and only the first had been done. `position: fixed` resolves against the viewport unless an ancestor makes itself the containing block, so the modal scrim and dialog in Overlay.module.css — `fixed`, `inset: 0`, centred, because in the standalone app they are meant to cover the page — were positioned and sized against the window. Layout and paint containment makes the container the containing block for those descendants and clips what we paint to our own box. The second-call-on-top symptom goes with it, since the dialog now stays inside the first call's box and there is nothing to overlap. Two sibling components still cannot draw over one another by construction, neither being able to leave its own stacking context, but that only shows if a host overlaps them. Containment clips a box measured in viewport units but cannot resize it, so anything positioned that way is simply put somewhere outside the container and disappears. That applied to the reactions overlay, a `100vw` by `100vh` box, and to the reaction picker, which sits at `82vh` so as to appear near the footer it belongs to. Both are positioned against whatever Element Call treats as its root — `[data-overlay-container]` in the app, which is the size of the page, and the host's container when embedded — so percentages mean the same thing there and the right thing here. The earpiece overlay is `inset: 0` with no viewport units, so containment is enough for it. Three viewport-relative sizes remain, all of which need more than a change of unit: the lobby's video preview is `50vh` on a flex item with an aspect ratio, `--content-inset-*` ramps up to a desktop inset from the window width, and the picker's `max-width` cap is the window's. The clipping cuts both ways: a menu near the edge of a small container is trimmed rather than overflowing into the host. That is the trade an embedded component makes. --- component/ElementCall.module.css | 17 ++++++++++++++--- src/button/ReactionToggleButton.module.css | 7 ++++++- src/room/ReactionsOverlay.module.css | 15 +++++++++------ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/component/ElementCall.module.css b/component/ElementCall.module.css index 461ad9b0..f99dccb1 100644 --- a/component/ElementCall.module.css +++ b/component/ElementCall.module.css @@ -6,15 +6,26 @@ Please see LICENSE in the repository root for full details. */ /* The container a host mounts us into. It fills whatever space the host gives -it, and establishes a stacking context of its own so that our overlays and -modals cannot escape it — which is the whole reason for embedding rather than -using an iframe. */ +it, and nothing we draw may leave it. + +That takes two separate things, which are easy to mistake for one. `isolation` +gives us a stacking context, so nothing inside can be layered above the host's +own interface. Containment makes us the containing block for `position: fixed` +descendants, and clips what we paint to our own box: without it, the modal +scrim and dialog — which are positioned `fixed` and centred, since in the app +they are meant to cover the page — resolve against the viewport and appear in +the middle of the host's window rather than in the middle of the call. + +The clipping cuts both ways: a menu near the edge of a small container is +trimmed rather than overflowing into the host. That is the trade being an +embedded component makes. */ .root { display: flex; flex-direction: column; inline-size: 100%; block-size: 100%; isolation: isolate; + contain: layout paint; position: relative; background-color: var(--cpd-color-bg-canvas-default); color: var(--cpd-color-text-primary); diff --git a/src/button/ReactionToggleButton.module.css b/src/button/ReactionToggleButton.module.css index 90c6af02..705d4d9e 100644 --- a/src/button/ReactionToggleButton.module.css +++ b/src/button/ReactionToggleButton.module.css @@ -19,7 +19,12 @@ } div.reactionPopupMenuRoot.reactionPopupMenuModal { - --overlay-top: 82vh; + /* Down near the footer it belongs to, rather than centred like other modals. + A percentage, not a viewport unit: the overlay is positioned `fixed`, so this + resolves against the page in the standalone app and against the container + when a host embeds us — where 82vh would put it below the container + entirely. */ + --overlay-top: 82%; width: fit-content; } diff --git a/src/room/ReactionsOverlay.module.css b/src/room/ReactionsOverlay.module.css index 3738dc09..618adbf3 100644 --- a/src/room/ReactionsOverlay.module.css +++ b/src/room/ReactionsOverlay.module.css @@ -3,8 +3,11 @@ display: inline; z-index: 2; pointer-events: none; - width: 100vw; - height: 100vh; + /* Percentages, not viewport units: the containing block is the element + Element Call treats as its root, which is the page in the standalone app but + the container a host gave us when embedded. */ + width: 100%; + height: 100%; left: 0; top: 0; } @@ -16,7 +19,7 @@ animation-name: reaction-up; width: fit-content; position: relative; - top: 80vh; + top: 80%; } @keyframes reaction-up { @@ -24,7 +27,7 @@ opacity: 1; translate: 0 0; scale: 200%; - top: 80vh; + top: 80%; } to { @@ -48,7 +51,7 @@ .reaction { font-size: 48pt; animation-name: reaction-up-reduced; - top: calc(-50vh + (48pt / 2)); - left: calc(50vw - (48pt / 2)) !important; + top: calc(-50% + (48pt / 2)); + left: calc(50% - (48pt / 2)) !important; } } From f8f5caaa0990096b45ae39343ec9b525808ac7fe Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 3 Sep 2026 16:50:30 +0100 Subject: [PATCH 037/154] Log remote participant and track events per LiveKit connection Rageshakes contained nothing about the state of remote tracks, so a tile showing the wrong mute or video state for a member could not be diagnosed. Log connect/disconnect, publish/unpublish, subscribe/ unsubscribe, subscription failures, remote mute/unmute and stream state changes on each Connection's logger, and remove the listeners when the connection scope ends. --- .../remoteMembers/Connection.test.ts | 78 +++++++++++++++- .../CallViewModel/remoteMembers/Connection.ts | 88 +++++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/src/state/CallViewModel/remoteMembers/Connection.test.ts b/src/state/CallViewModel/remoteMembers/Connection.test.ts index 72329585..9d098d44 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.test.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.test.ts @@ -19,14 +19,17 @@ import { import { type LocalParticipant, type RemoteParticipant, + type RemoteTrack, + type RemoteTrackPublication, type Room as LivekitRoom, RoomEvent, + Track, ConnectionState as LivekitConnectionState, } from "livekit-client"; import fetchMock from "fetch-mock"; import EventEmitter from "events"; import { type IOpenIDToken } from "matrix-js-sdk"; -import { logger } from "matrix-js-sdk/lib/logger"; +import { logger, type Logger } from "matrix-js-sdk/lib/logger"; import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc"; import { @@ -509,6 +512,79 @@ describe("remote participants", () => { }); }); +describe("remote track logging", () => { + it("logs remote participant and track events on the connection logger", () => { + setupTest(); + const info = vi.fn(); + const testLogger = { + getChild: (): unknown => testLogger, + info, + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + new Connection( + { + client, + roomId: ROOM_ID, + transport: livekitFocus, + scope: testScope, + ownMembershipIdentity: ownMemberMock, + livekitRoomFactory: () => fakeLivekitRoom, + }, + testLogger, + ); + info.mockClear(); // drop the constructor log line + + const bob = mockRemoteParticipant({ + identity: "@bob:example.org:DEV111", + sid: "PA_bob", + }); + const pub = { + kind: "audio", + source: "microphone", + trackSid: "TR_mic", + isMuted: false, + } as unknown as RemoteTrackPublication; + const messages = (): string[] => info.mock.calls.map((c) => c[0] as string); + + fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, bob); + fakeLivekitRoom.emit( + RoomEvent.TrackSubscribed, + {} as RemoteTrack, + pub, + bob, + ); + fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, bob); + fakeLivekitRoom.emit( + RoomEvent.TrackStreamStateChanged, + pub, + Track.StreamState.Paused, + bob, + ); + expect(messages()).toEqual([ + "Participant connected: @bob:example.org:DEV111 (PA_bob)", + "Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 muted=false", + "Muted: audio microphone TR_mic of @bob:example.org:DEV111", + "Stream paused: audio microphone TR_mic of @bob:example.org:DEV111", + ]); + + // Local mute events are already logged by the Publisher + fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, { + ...fakeLocalParticipant, + isLocal: true, + } as unknown as LocalParticipant); + expect(messages().filter((m) => m.startsWith("Muted"))).toHaveLength(1); + + // Listeners are removed when the scope ends + testScope.end(); + fakeLivekitRoom.emit(RoomEvent.ParticipantDisconnected, bob); + expect( + messages().filter((m) => m.startsWith("Participant disconnected")), + ).toHaveLength(0); + }); +}); + // // NOT USED ANYMORE ? // diff --git a/src/state/CallViewModel/remoteMembers/Connection.ts b/src/state/CallViewModel/remoteMembers/Connection.ts index f320e630..9310c9b8 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.ts @@ -13,8 +13,14 @@ import { import { ConnectionError, ConnectionErrorReason, + type Participant, type RemoteParticipant, + type RemoteTrackPublication, type Room as LivekitRoom, + RoomEvent, + type SubscriptionError, + type Track, + type TrackPublication, } from "livekit-client"; import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc"; import { BehaviorSubject, map } from "rxjs"; @@ -164,6 +170,7 @@ export class Connection { // Only tracks remote participants connectedParticipantsObserver(this.livekitRoom), ); + this.logRemoteTrackEvents(); scope.onEnd(() => { this.logger.info(`Connection scope ended, stopping connection`); @@ -171,6 +178,87 @@ export class Connection { }); } + /** + * Logs the lifecycle of remote participants and their tracks as seen by + * livekit-client, so that rageshakes show what a tile should have been + * rendering (published, subscribed, muted, paused) for each remote member. + */ + private logRemoteTrackEvents(): void { + const room = this.livekitRoom; + const log = this.logger.getChild("[RemoteTracks]"); + const track = (pub: TrackPublication, p: Participant): string => + `${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity}`; + + const onParticipantConnected = (p: RemoteParticipant): void => + log.info(`Participant connected: ${p.identity} (${p.sid})`); + const onParticipantDisconnected = (p: RemoteParticipant): void => + log.info(`Participant disconnected: ${p.identity} (${p.sid})`); + const onTrackPublished = ( + pub: RemoteTrackPublication, + p: RemoteParticipant, + ): void => log.info(`Published: ${track(pub, p)} muted=${pub.isMuted}`); + const onTrackUnpublished = ( + pub: RemoteTrackPublication, + p: RemoteParticipant, + ): void => log.info(`Unpublished: ${track(pub, p)}`); + const onTrackSubscribed = ( + _t: Track, + pub: RemoteTrackPublication, + p: RemoteParticipant, + ): void => log.info(`Subscribed: ${track(pub, p)} muted=${pub.isMuted}`); + const onTrackUnsubscribed = ( + _t: Track, + pub: RemoteTrackPublication, + p: RemoteParticipant, + ): void => log.info(`Unsubscribed: ${track(pub, p)}`); + const onTrackSubscriptionFailed = ( + trackSid: string, + p: RemoteParticipant, + reason?: SubscriptionError, + ): void => + log.warn( + `Subscription failed: ${trackSid} of ${p.identity} reason=${reason}`, + ); + // Mute events also fire for the local participant, which the Publisher + // already logs. + const onTrackMuted = (pub: TrackPublication, p: Participant): void => { + if (!p.isLocal) log.info(`Muted: ${track(pub, p)}`); + }; + const onTrackUnmuted = (pub: TrackPublication, p: Participant): void => { + if (!p.isLocal) log.info(`Unmuted: ${track(pub, p)}`); + }; + const onTrackStreamStateChanged = ( + pub: RemoteTrackPublication, + state: Track.StreamState, + p: RemoteParticipant, + ): void => log.info(`Stream ${state}: ${track(pub, p)}`); + + room + .on(RoomEvent.ParticipantConnected, onParticipantConnected) + .on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) + .on(RoomEvent.TrackPublished, onTrackPublished) + .on(RoomEvent.TrackUnpublished, onTrackUnpublished) + .on(RoomEvent.TrackSubscribed, onTrackSubscribed) + .on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) + .on(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed) + .on(RoomEvent.TrackMuted, onTrackMuted) + .on(RoomEvent.TrackUnmuted, onTrackUnmuted) + .on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged); + this.scope.onEnd(() => { + room + .off(RoomEvent.ParticipantConnected, onParticipantConnected) + .off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected) + .off(RoomEvent.TrackPublished, onTrackPublished) + .off(RoomEvent.TrackUnpublished, onTrackUnpublished) + .off(RoomEvent.TrackSubscribed, onTrackSubscribed) + .off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed) + .off(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed) + .off(RoomEvent.TrackMuted, onTrackMuted) + .off(RoomEvent.TrackUnmuted, onTrackUnmuted) + .off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged); + }); + } + /** * Starts the connection. * From 0b95fd5a65a87b03a4d52e1d97f5b3cc622adbc4 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 3 Sep 2026 17:07:08 +0100 Subject: [PATCH 038/154] Log remote member participant matching and tile waiting state A tile shows "Waiting for media" for as long as its MatrixRTC member cannot be matched to a LiveKit participant. Log each transition of that match and of the tile's waitingForMedia state so rageshakes can tie a stuck tile to the LiveKit participant and track events. --- .../MatrixLivekitMembers.test.ts | 7 ++++- .../remoteMembers/MatrixLivekitMembers.ts | 10 ++++++- src/state/media/RemoteUserMediaViewModel.ts | 26 ++++++++++++------- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts index 244d70ae..09c82789 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts @@ -5,7 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { logger } from "matrix-js-sdk/lib/logger"; import { type CallMembership, type LivekitTransport, @@ -77,6 +78,7 @@ function epochMeWith$( } test("should signal participant not yet connected to livekit", async () => { + const info = vi.spyOn(logger, "info"); const mockedMemberships$ = new BehaviorSubject([bobMembership]); const mockConnectionManagerData$ = new BehaviorSubject( new ConnectionManagerData(), @@ -108,6 +110,9 @@ test("should signal participant not yet connected to livekit", async () => { return true; }, ); + expect(info).toHaveBeenCalledWith( + "[RemoteMatrixLivekitMembers] @bob:example.org:DEV000: LiveKit participant missing", + ); }); // Helper to create epoch'ed memberships$ and membershipsWithTransport$ from memberships observable. diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts index 0b93a274..e884d382 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts @@ -11,6 +11,7 @@ import { type LivekitTransportConfig, } from "matrix-js-sdk/lib/matrixrtc"; import { combineLatest, filter, map } from "rxjs"; +import { logger } from "matrix-js-sdk/lib/logger"; import { type Behavior } from "../../Behavior"; import { type IConnectionManager } from "./ConnectionManager"; @@ -133,8 +134,15 @@ export function createRemoteMatrixLivekitMembers$({ } }, // Each update where the key of the generator array do not change will result in updates to the `data$` behavior. - (scope, data$, userId, _deviceId, _memberId, _rtcBackendIdentity) => { + (scope, data$, userId, _deviceId, _memberId, rtcBackendIdentity) => { const { participant$, ...rest } = scope.splitBehavior(data$); + // Log whether the member could be matched to a LiveKit participant, + // since a tile shows "waiting for media" for as long as it cannot. + participant$.pipe(scope.bind()).subscribe((p) => { + logger.info( + `[RemoteMatrixLivekitMembers] ${rtcBackendIdentity}: LiveKit participant ${p ? `matched (${p.sid})` : "missing"}`, + ); + }); // will only get called once per backend identity. // updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent. return { diff --git a/src/state/media/RemoteUserMediaViewModel.ts b/src/state/media/RemoteUserMediaViewModel.ts index 4307dea4..6daa381a 100644 --- a/src/state/media/RemoteUserMediaViewModel.ts +++ b/src/state/media/RemoteUserMediaViewModel.ts @@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details. import { type RemoteParticipant } from "livekit-client"; import { combineLatest, map, of, switchMap } from "rxjs"; +import { logger } from "matrix-js-sdk/lib/logger"; import { type Behavior } from "../Behavior"; import { createVolumeControls, type VolumeControls } from "../VolumeControls"; @@ -45,6 +46,20 @@ export function createRemoteUserMedia( statsType: "inbound-rtp", }); + const waitingForMedia$ = scope.behavior( + combineLatest( + [inputs.livekitRoom$, inputs.participant$], + (livekitRoom, participant) => + // If livekitRoom is undefined, the user is not attempting to publish on + // any transport and so we shouldn't expect a participant. (They might + // be a subscribe-only bot for example.) + livekitRoom !== undefined && participant === null, + ), + ); + waitingForMedia$.pipe(scope.bind()).subscribe((waiting) => { + logger.info(`[RemoteUserMedia ${inputs.id}] waitingForMedia=${waiting}`); + }); + return { ...baseUserMedia, ...createVolumeControls(scope, { @@ -68,15 +83,6 @@ export function createRemoteUserMedia( ), ), ), - waitingForMedia$: scope.behavior( - combineLatest( - [inputs.livekitRoom$, inputs.participant$], - (livekitRoom, participant) => - // If livekitRoom is undefined, the user is not attempting to publish on - // any transport and so we shouldn't expect a participant. (They might - // be a subscribe-only bot for example.) - livekitRoom !== undefined && participant === null, - ), - ), + waitingForMedia$, }; } From ace78de749ebb067cd258cd6be61610b14ca969a Mon Sep 17 00:00:00 2001 From: Valere Date: Thu, 3 Sep 2026 18:09:12 +0200 Subject: [PATCH 039/154] Cover Element Call as a component with end-to-end tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widget tests cannot reach what makes a component different, because the iframe used to guarantee it: that Element Call stays inside the space it was given, and that two of them can exist in one page. So the development harness gets driven by Playwright. Three tests. Two components in one page holding a real call between two devices of one account. The settings dialog and the reaction picker staying inside the container the host gave, asserted by bounding box. And the host bridge reporting in both directions, including a host-initiated mute coming back as a report of the new state. The containment test is the one worth having: both of the escapes found by running the harness by hand — the settings dialog centred on the window, and the reaction picker at 82vh landing below the container — would have failed it, and neither was visible to typechecking, linting or the unit tests. Users and rooms are created through the Synapse admin and client-server APIs rather than by driving an interface, and the harness now takes its credentials from its own query string so that a test can say which account and room to use. A host reading its own URL is proper; it was Element Call doing so that was the mistake. Playwright gains a second web server for the harness on port 3001, a Vite dev server whether or not the app itself is served from Docker, since the harness is a development page with nothing to build. --- component/dev/Harness.tsx | 31 ++++-- playwright.config.ts | 35 +++++-- playwright/component/component-call.spec.ts | 102 ++++++++++++++++++ playwright/component/harness.ts | 110 ++++++++++++++++++++ 4 files changed, 261 insertions(+), 17 deletions(-) create mode 100644 playwright/component/component-call.spec.ts create mode 100644 playwright/component/harness.ts diff --git a/component/dev/Harness.tsx b/component/dev/Harness.tsx index ddb69d32..141f9492 100644 --- a/component/dev/Harness.tsx +++ b/component/dev/Harness.tsx @@ -37,16 +37,31 @@ const DEFAULT_CREDENTIALS: Credentials = { room: "", }; -/** The last credentials used, so that a reload does not mean typing them again. */ +/** + * The credentials to start with: the last ones used, so that a reload does not + * mean typing them again, overridden by anything in the query string. + * + * A host reading its own URL is entirely proper — it was Element Call doing so + * that was the mistake. It lets the end-to-end tests, or a shared link, say + * which account and room to use. + */ function loadCredentials(): Credentials { + let stored: Partial = {}; try { - const stored = localStorage.getItem(CREDENTIALS_KEY); - if (stored !== null) - return { ...DEFAULT_CREDENTIALS, ...(JSON.parse(stored) as Credentials) }; + const json = localStorage.getItem(CREDENTIALS_KEY); + if (json !== null) stored = JSON.parse(json) as Credentials; } catch (e) { logger.warn("Could not read the stored harness credentials", e); } - return DEFAULT_CREDENTIALS; + + const query = new URLSearchParams(location.search); + const fromUrl = Object.fromEntries( + (["homeserver", "username", "password", "room"] as const) + .map((name) => [name, query.get(name)]) + .filter(([, value]) => value !== null), + ) as Partial; + + return { ...DEFAULT_CREDENTIALS, ...stored, ...fromUrl }; } interface Session { @@ -88,7 +103,7 @@ const Pane: FC<{ ); return ( -
+
{session.label} {session.client.getDeviceId()} @@ -110,7 +125,7 @@ const Pane: FC<{
{/* Resizable, because how Element Call copes with the size it is given is one of the things we cannot find out from the standalone app */} -
+
{mounted && ( { ))}
-
+

Host bridge

    {entries.map((entry, i) => ( diff --git a/playwright.config.ts b/playwright.config.ts index 85e65e13..73112c7a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,6 +11,8 @@ import { join } from "path"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts"; + const baseURL = process.env.USE_DOCKER ? "http://localhost:8080" : "https://localhost:3000"; @@ -115,14 +117,29 @@ export default defineConfig({ ], /* Run your local dev server before starting the tests */ - webServer: { - command: "./scripts/playwright-webserver-command.sh", - url: baseURL, - reuseExistingServer: !process.env.CI, - ignoreHTTPSErrors: true, - gracefulShutdown: { - signal: "SIGTERM", - timeout: 500, + webServer: [ + { + command: "./scripts/playwright-webserver-command.sh", + url: baseURL, + reuseExistingServer: !process.env.CI, + ignoreHTTPSErrors: true, + gracefulShutdown: { + signal: "SIGTERM", + timeout: 500, + }, }, - }, + { + // The harness that embeds Element Call as a component. Always a Vite dev + // server, whether or not the app itself is being served from Docker, + // since there is nothing to build: it is a development page only. + command: "pnpm dev:component", + url: COMPONENT_HARNESS_URL, + reuseExistingServer: !process.env.CI, + ignoreHTTPSErrors: true, + gracefulShutdown: { + signal: "SIGTERM", + timeout: 500, + }, + }, + ], }); diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts new file mode 100644 index 00000000..233b826a --- /dev/null +++ b/playwright/component/component-call.spec.ts @@ -0,0 +1,102 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, type Locator, test } from "@playwright/test"; + +import { createUserAndRoom, expectWithin, startHarness } from "./harness.ts"; + +/** + * Element Call embedded as a React component, driven through the development + * harness in `component/dev`. + * + * What these cover that the widget tests cannot is everything that follows from + * sharing a page with a host: whether Element Call stays inside the space it + * was given, and whether two of it can exist at once. As a widget, the iframe + * guaranteed both. + */ + +/** The settings button, whichever of the two the footer is currently showing. */ +function settingsButton(pane: Locator): Locator { + return pane + .getByTestId("settings-bottom-left") + .or(pane.getByTestId("settings-bottom-center")) + .filter({ visible: true }) + .first(); +} + +test("holds a call between two components on one page", async ({ page }) => { + const { username, roomId } = await createUserAndRoom("twocomponents"); + const panes = await startHarness(page, username, roomId); + + // Each component shows a lobby of its own, and neither has joined anything + // just by being rendered + for (const index of [0, 1]) + await expect(panes.nth(index).getByTestId("lobby_joinCall")).toBeVisible({ + timeout: 60_000, + }); + + for (const index of [0, 1]) + await panes.nth(index).getByTestId("lobby_joinCall").click(); + + // Two devices of one account, so each component should see itself and the + // other. This is the part that proves two Element Calls in one page are two + // calls, and not one shared thing wearing two hats. + for (const index of [0, 1]) + await expect(panes.nth(index).getByTestId("videoTile")).toHaveCount(2, { + timeout: 60_000, + }); +}); + +test("keeps its modals inside the container it was given", async ({ page }) => { + const { username, roomId } = await createUserAndRoom("containment"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + const container = pane.getByTestId("call-container"); + + await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); + await expect(pane.getByTestId("footer-container")).toBeVisible({ + timeout: 60_000, + }); + + // Both of these are positioned `fixed`, and were centred on the window + // rather than the container until it was made a containing block. The + // settings dialog spilled over the host's interface; the reaction picker sat + // at 82vh, which put it below the container entirely and so out of sight. + await settingsButton(pane).click(); + await expectWithin(pane.getByRole("dialog"), container); + await pane.getByTestId("modal_close").click(); + + await pane.getByRole("button", { name: "Reactions" }).click(); + await expectWithin( + pane.getByRole("dialog", { name: "Pick reaction" }), + container, + ); +}); + +test("tells its host what it is doing", async ({ page }) => { + const { username, roomId } = await createUserAndRoom("hostbridge"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + const log = page.getByTestId("bridge-log"); + + // Every component reports to its host through the bridge, whether that host + // is a widget container or an application embedding it directly + await expect(log).toContainText("contentLoaded", { timeout: 60_000 }); + + await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); + await expect(log).toContainText("notifyJoined", { timeout: 60_000 }); + await expect(log).toContainText("setAlwaysOnScreen(true)", { + timeout: 60_000, + }); + + // And takes instructions back: the host asking for a mute should come back + // as the component reporting the new state + await pane.getByRole("button", { name: "Mute" }).click(); + await expect(log).toContainText("notifyDeviceMute(audio: false", { + timeout: 30_000, + }); +}); diff --git a/playwright/component/harness.ts b/playwright/component/harness.ts new file mode 100644 index 00000000..4602f22d --- /dev/null +++ b/playwright/component/harness.ts @@ -0,0 +1,110 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, type Locator, type Page } from "@playwright/test"; + +import { SynapseAdmin } from "../utils/synapse-admin.ts"; + +/** + * Where the component harness is served — `component/dev`, which embeds Element + * Call the way a host application would. Not the `baseURL` the rest of the + * suite uses: these tests drive a page that contains Element Call rather than + * Element Call itself. + */ +export const COMPONENT_HARNESS_URL = "https://localhost:3001"; + +const HOMESERVER_URL = "https://synapse.m.localhost"; +const PASSWORD = "foobarbaz1!"; + +/** + * Registers a user through the Synapse admin API and creates a room for it to + * call in, without touching a browser. The harness signs into this account + * twice, giving two devices in one page and so a real call between the two + * components. + */ +export async function createUserAndRoom( + name: string, +): Promise<{ username: string; roomId: string }> { + const username = `${name}_${Date.now()}`; + const { access_token: accessToken } = await SynapseAdmin.forHomeserver( + HOMESERVER_URL, + ).registerUser(username, PASSWORD, name); + + const response = await fetch( + `${HOMESERVER_URL}/_matrix/client/v3/createRoom`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: `${name}'s call`, preset: "private_chat" }), + }, + ); + if (!response.ok) + throw new Error( + `Could not create a room: ${response.status} ${await response.text()}`, + ); + const { room_id: roomId } = (await response.json()) as { room_id: string }; + + return { username, roomId }; +} + +/** + * Opens the harness signed in as the given user, and waits for both embedded + * calls to appear. + * + * @returns The two containers the host gave Element Call, in order. + */ +export async function startHarness( + page: Page, + username: string, + roomId: string, +): Promise { + const query = new URLSearchParams({ + homeserver: HOMESERVER_URL, + username, + password: PASSWORD, + room: roomId, + }); + await page.goto(`${COMPONENT_HARNESS_URL}/?${query.toString()}`); + await page.getByRole("button", { name: "Start" }).click(); + + const panes = page.getByTestId("call-pane"); + // Two logins, two crypto setups and two initial syncs happen first + await expect(panes).toHaveCount(2, { timeout: 120_000 }); + return panes; +} + +/** + * Asserts that one element is drawn entirely inside another. + * + * This is the check that being a component rather than an iframe costs us: an + * iframe could not paint outside itself whatever its stylesheets said, whereas + * a component shares the page and has to be made to stay put. + */ +export async function expectWithin( + inner: Locator, + outer: Locator, +): Promise { + await expect(inner).toBeVisible(); + const innerBox = await inner.boundingBox(); + const outerBox = await outer.boundingBox(); + if (innerBox === null || outerBox === null) + throw new Error("Expected both elements to be laid out"); + + // A pixel of slack, for subpixel layout + const slack = 1; + expect(innerBox.x).toBeGreaterThanOrEqual(outerBox.x - slack); + expect(innerBox.y).toBeGreaterThanOrEqual(outerBox.y - slack); + expect(innerBox.x + innerBox.width).toBeLessThanOrEqual( + outerBox.x + outerBox.width + slack, + ); + expect(innerBox.y + innerBox.height).toBeLessThanOrEqual( + outerBox.y + outerBox.height + slack, + ); +} From 2ab2197c01d23d51bd3a6ade455f7b14c919bdf3 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 3 Sep 2026 17:26:38 +0100 Subject: [PATCH 040/154] Warn when a behavior is re-entered while delivering a value rxjs delivers a nested emission to every subscriber and then resumes delivering the outer, older value to the remaining subscribers, which leaves them permanently out of sync. Log the first occurrence per behavior with a stack trace so the re-entrant path can be identified from a rageshake. --- src/state/ObservableScope.test.ts | 27 +++++++++++++++++++++++++++ src/state/ObservableScope.ts | 24 +++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/state/ObservableScope.test.ts b/src/state/ObservableScope.test.ts index 31728f39..353ecaed 100644 --- a/src/state/ObservableScope.test.ts +++ b/src/state/ObservableScope.test.ts @@ -237,3 +237,30 @@ describe("Reconcile", () => { expect(setup).toHaveBeenCalledWith(1); }); }); + +describe("behavior", () => { + it("warns when a subscriber re-enters the behavior synchronously", () => { + const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const scope = new ObservableScope(); + const source$ = new Subject(); + const behavior$ = scope.behavior(source$, 0); + // A subscriber that reacts to the value 1 by synchronously emitting 2 + behavior$.subscribe((v) => { + if (v === 1) source$.next(2); + }); + const seen: number[] = []; + behavior$.subscribe((v) => seen.push(v)); + + source$.next(1); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Behavior re-entered"), + expect.any(String), + ); + // Documents the hazard the warning is about: the later subscriber ends up + // with the stale value 1 even though the behavior's value is 2. + expect(behavior$.value).toBe(2); + expect(seen.at(-1)).toBe(1); + scope.end(); + }); +}); diff --git a/src/state/ObservableScope.ts b/src/state/ObservableScope.ts index e3fc644f..0a64e2a4 100644 --- a/src/state/ObservableScope.ts +++ b/src/state/ObservableScope.ts @@ -20,6 +20,8 @@ import { takeUntil, } from "rxjs"; +import { logger } from "matrix-js-sdk/lib/logger"; + import { type Behavior } from "./Behavior"; type MonoTypeOperator = (o: Observable) => Observable; @@ -73,9 +75,29 @@ export class ObservableScope { // they will no longer re-emit their current value upon subscription. We want // to support Observables that complete (for example `of({})`), so we have to // take care to not propagate the completion event. + // If a subscriber synchronously causes this same behavior to emit again, + // rxjs delivers the nested value to every subscriber first and then + // resumes delivering the outer (older) value to the remaining subscribers, + // leaving them permanently out of sync with the others. Log the first + // occurrence with a stack trace so that the re-entrant path can be found. + let delivering = false; + let reentryReported = false; setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({ next(value) { - subject$.next(value); + if (delivering && !reentryReported) { + reentryReported = true; + logger.warn( + "Behavior re-entered while delivering a value; later subscribers will be left with a stale value", + new Error().stack, + ); + } + const wasDelivering = delivering; + delivering = true; + try { + subject$.next(value); + } finally { + delivering = wasDelivering; + } }, error(err: unknown) { subject$.error(err); From b1393998a7f7282cb7f4a4727edc2e91bca61378 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 3 Sep 2026 18:08:53 +0100 Subject: [PATCH 041/154] Report re-entrancy depth and tag torn behaviors A single re-entrant emission can only strand a contiguous run of a behavior's subscribers, so a tile that is both 'speaking' and 'waiting for media' (a middle subscriber stranded) implies a nested re-entry. Report the nesting depth and log each new depth once instead of only the first re-entry, and tag splitBehavior-derived behaviors with their field name so the warning identifies which behavior tore. --- src/state/ObservableScope.test.ts | 26 ++++++++++++++---------- src/state/ObservableScope.ts | 33 ++++++++++++++++++++----------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/src/state/ObservableScope.test.ts b/src/state/ObservableScope.test.ts index 353ecaed..24b5d268 100644 --- a/src/state/ObservableScope.test.ts +++ b/src/state/ObservableScope.test.ts @@ -239,27 +239,33 @@ describe("Reconcile", () => { }); describe("behavior", () => { - it("warns when a subscriber re-enters the behavior synchronously", () => { + it("warns with the tag and nesting depth, logging each new depth once", () => { const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); const scope = new ObservableScope(); const source$ = new Subject(); - const behavior$ = scope.behavior(source$, 0); - // A subscriber that reacts to the value 1 by synchronously emitting 2 + const behavior$ = scope.behavior(source$, 0, "tagged"); + // Re-enter once on value 1 (depth 2), then a nested re-entry on value 2 + // (depth 3), which is the shape needed to strand a middle subscriber. behavior$.subscribe((v) => { if (v === 1) source$.next(2); }); + behavior$.subscribe((v) => { + if (v === 2) source$.next(3); + }); const seen: number[] = []; behavior$.subscribe((v) => seen.push(v)); source$.next(1); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("Behavior re-entered"), - expect.any(String), - ); - // Documents the hazard the warning is about: the later subscriber ends up - // with the stale value 1 even though the behavior's value is 2. - expect(behavior$.value).toBe(2); + const messages = warn.mock.calls.map((c) => c[0] as string); + expect(messages).toEqual([ + expect.stringContaining("Behavior (tagged) re-entered at depth 2"), + expect.stringContaining("Behavior (tagged) re-entered at depth 3"), + ]); + warn.mock.calls.forEach((c) => expect(c[1]).toEqual(expect.any(String))); + // The behavior settles on the newest value while the last subscriber is + // left stranded on the oldest. + expect(behavior$.value).toBe(3); expect(seen.at(-1)).toBe(1); scope.end(); }); diff --git a/src/state/ObservableScope.ts b/src/state/ObservableScope.ts index 0a64e2a4..d0cfb7e9 100644 --- a/src/state/ObservableScope.ts +++ b/src/state/ObservableScope.ts @@ -68,6 +68,9 @@ export class ObservableScope { public behavior( setValue$: Observable, initialValue: T | typeof nothing = nothing, + // An optional label used only in the re-entrancy warning below, so that a + // torn behavior can be identified from a rageshake. + tag?: string, ): Behavior { const subject$ = new BehaviorSubject(initialValue); // Push values from the Observable into the BehaviorSubject. @@ -78,25 +81,29 @@ export class ObservableScope { // If a subscriber synchronously causes this same behavior to emit again, // rxjs delivers the nested value to every subscriber first and then // resumes delivering the outer (older) value to the remaining subscribers, - // leaving them permanently out of sync with the others. Log the first - // occurrence with a stack trace so that the re-entrant path can be found. - let delivering = false; - let reentryReported = false; + // leaving them permanently out of sync with the others. A single such + // re-entry can only strand a contiguous run of subscribers; stranding a + // subscriber in the middle of the list needs a nested (deeper) re-entry, so + // we report the depth and log each new depth (with a stack trace) rather + // than only the first occurrence, to make a nested re-entry visible. + let depth = 0; + let maxReportedDepth = 1; setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({ next(value) { - if (delivering && !reentryReported) { - reentryReported = true; + if (depth > 0 && depth + 1 > maxReportedDepth) { + maxReportedDepth = depth + 1; logger.warn( - "Behavior re-entered while delivering a value; later subscribers will be left with a stale value", + `Behavior${tag ? ` (${tag})` : ""} re-entered at depth ${ + depth + 1 + } while delivering a value; later subscribers will be left with a stale value`, new Error().stack, ); } - const wasDelivering = delivering; - delivering = true; + depth++; try { subject$.next(value); } finally { - delivering = wasDelivering; + depth--; } }, error(err: unknown) { @@ -193,7 +200,11 @@ export class ObservableScope { return Object.fromEntries( Object.keys(input$.value).map((key) => [ `${key}$`, - this.behavior(input$.pipe(map((input) => input[key as keyof T]))), + this.behavior( + input$.pipe(map((input) => input[key as keyof T])), + nothing, + key, + ), ]), ) as SplitBehavior; } From dee4b05695dc9ead77c912d6dec7e3310e3963c7 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Thu, 3 Sep 2026 18:43:18 +0100 Subject: [PATCH 042/154] Diagnostics for duplicate participant matches and mid-delivery scope teardown Warn when ConnectionManagerData merges participants from a second connection to the same SFU URL, when more than one LiveKit participant matches a member's backend identity, and when a scope is ended while one of its behaviors is still delivering a value (which strands the later bound subscribers on the old value). The per-member match log now also names the connection the participant came from. --- .../remoteMembers/ConnectionManager.test.ts | 20 ++++++++++++++++- .../remoteMembers/ConnectionManager.ts | 9 ++++++-- .../MatrixLivekitMembers.test.ts | 2 +- .../remoteMembers/MatrixLivekitMembers.ts | 15 ++++++++----- src/state/ObservableScope.test.ts | 22 +++++++++++++++++++ src/state/ObservableScope.ts | 17 ++++++++++++-- 6 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts b/src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts index fada34be..809c8bcc 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts @@ -14,7 +14,7 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts"; import { createConnectionManager$, - type ConnectionManagerData, + ConnectionManagerData, } from "./ConnectionManager.ts"; import { type ConnectionFactory } from "./ConnectionFactory.ts"; import { type Connection } from "./Connection.ts"; @@ -203,6 +203,24 @@ describe("connections$ stream", () => { }); }); +describe("ConnectionManagerData", () => { + test("warns when a second connection to the same URL is merged", () => { + const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const data = new ConnectionManagerData(logger); + const connection = { transport: TRANSPORT_1 } as unknown as Connection; + const p = (identity: string): RemoteParticipant => + ({ identity }) as unknown as RemoteParticipant; + data.add(connection, [p("a")]); + data.add({ ...connection } as Connection, [p("b")]); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + "second connection to https://lk.example.org: existing [a], adding [b]", + ), + ); + expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2); + }); +}); + describe("connectionManagerData$ stream", () => { // Used in test to control fake connections' remoteParticipants$ streams let fakeRemoteParticipantsStreams: Map>; diff --git a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts index 727f68bc..5ebd2ce6 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts @@ -30,7 +30,7 @@ export class ConnectionManagerData { { connection: Connection; participants: RemoteParticipant[] } > = new Map(); - public constructor() {} + public constructor(private readonly logger?: Logger) {} public add(connection: Connection, participants: RemoteParticipant[]): void { const key = this.getKey(connection.transport); @@ -38,6 +38,11 @@ export class ConnectionManagerData { if (!existing) { this.store.set(key, { connection, participants }); } else { + // Transports are deduplicated by URL upstream, so this should never + // happen; if it does, members may be matched against the wrong room. + this.logger?.warn( + `Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`, + ); existing.participants.push(...participants); } } @@ -239,7 +244,7 @@ export function createConnectionManager$({ lists.reduce((data, { connection, participants }) => { data.add(connection, participants); return data; - }, new ConnectionManagerData()), + }, new ConnectionManagerData(logger)), epoch, ), ), diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts index 09c82789..fe762112 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts @@ -111,7 +111,7 @@ test("should signal participant not yet connected to livekit", async () => { }, ); expect(info).toHaveBeenCalledWith( - "[RemoteMatrixLivekitMembers] @bob:example.org:DEV000: LiveKit participant missing", + "[RemoteMatrixLivekitMembers] @bob:example.org:DEV000: LiveKit participant missing on no connection", ); }); diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts index e884d382..76f4670e 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts @@ -111,13 +111,17 @@ export function createRemoteMatrixLivekitMembers$({ const participants = transport ? managerData.getParticipantsForTransport(transport) : []; - const participant = - participants.find( - (p) => p.identity == membership.rtcBackendIdentity, - ) ?? null; + const matches = participants.filter( + (p) => p.identity == membership.rtcBackendIdentity, + ); + const participant = matches[0] ?? null; const connection = transport ? managerData.getConnectionForTransport(transport) : null; + if (matches.length > 1) + logger.warn( + `[RemoteMatrixLivekitMembers] ${membership.rtcBackendIdentity}: ${matches.length} LiveKit participants match (sids ${matches.map((p) => p.sid).join(", ")}), using ${participant?.sid}`, + ); yield { // This could just be the backend identity without the other keys. @@ -139,8 +143,9 @@ export function createRemoteMatrixLivekitMembers$({ // Log whether the member could be matched to a LiveKit participant, // since a tile shows "waiting for media" for as long as it cannot. participant$.pipe(scope.bind()).subscribe((p) => { + const url = data$.value.connection?.transport.livekit_service_url; logger.info( - `[RemoteMatrixLivekitMembers] ${rtcBackendIdentity}: LiveKit participant ${p ? `matched (${p.sid})` : "missing"}`, + `[RemoteMatrixLivekitMembers] ${rtcBackendIdentity}: LiveKit participant ${p ? `matched (${p.sid})` : "missing"} on ${url ?? "no connection"}`, ); }); // will only get called once per backend identity. diff --git a/src/state/ObservableScope.test.ts b/src/state/ObservableScope.test.ts index 24b5d268..12bd39ce 100644 --- a/src/state/ObservableScope.test.ts +++ b/src/state/ObservableScope.test.ts @@ -269,4 +269,26 @@ describe("behavior", () => { expect(seen.at(-1)).toBe(1); scope.end(); }); + + it("warns when the scope is ended while a behavior is mid-delivery", () => { + const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const scope = new ObservableScope(); + const source$ = new Subject(); + const behavior$ = scope.behavior(source$, 0); + behavior$.subscribe((v) => { + if (v === 1) scope.end(); + }); + // A derived behavior is bound to the scope, so ending the scope + // mid-delivery unsubscribes it before the value reaches it. + const derived$ = scope.behavior(behavior$); + + source$.next(1); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Scope ended while 1 of its behaviors"), + expect.any(String), + ); + expect(behavior$.value).toBe(1); + expect(derived$.value).toBe(0); + }); }); diff --git a/src/state/ObservableScope.ts b/src/state/ObservableScope.ts index d0cfb7e9..8f09cb6e 100644 --- a/src/state/ObservableScope.ts +++ b/src/state/ObservableScope.ts @@ -36,6 +36,12 @@ const nothing = Symbol("nothing"); * A scope which limits the execution lifetime of its bound Observables. */ export class ObservableScope { + /** + * Number of this scope's behaviors currently mid-delivery. Used to detect a + * scope being torn down synchronously from within one of its own emissions. + */ + private delivering = 0; + private readonly ended$ = new BehaviorSubject(false); private readonly bindImpl: MonoTypeOperator = takeUntil( @@ -89,7 +95,7 @@ export class ObservableScope { let depth = 0; let maxReportedDepth = 1; setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({ - next(value) { + next: (value) => { if (depth > 0 && depth + 1 > maxReportedDepth) { maxReportedDepth = depth + 1; logger.warn( @@ -100,13 +106,15 @@ export class ObservableScope { ); } depth++; + this.delivering++; try { subject$.next(value); } finally { depth--; + this.delivering--; } }, - error(err: unknown) { + error: (err: unknown) => { subject$.error(err); }, }); @@ -119,6 +127,11 @@ export class ObservableScope { * Ends the scope, causing any bound Observables to complete. */ public end(): void { + if (this.delivering > 0) + logger.warn( + `Scope ended while ${this.delivering} of its behaviors were still delivering a value; later subscribers will be left with a stale value`, + new Error().stack, + ); this.ended$.next(true); } From 054913d17d1ad8e13b0aec221c0a9a2d9f315153 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 3 Sep 2026 20:11:18 +0200 Subject: [PATCH 043/154] Use testScope in ObservableScope tests --- src/state/ObservableScope.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/state/ObservableScope.test.ts b/src/state/ObservableScope.test.ts index 12bd39ce..25bb1432 100644 --- a/src/state/ObservableScope.test.ts +++ b/src/state/ObservableScope.test.ts @@ -16,7 +16,7 @@ import { ObservableScope, trackEpoch, } from "./ObservableScope"; -import { withTestScheduler } from "../utils/test"; +import { testScope, withTestScheduler } from "../utils/test"; describe("Epoch", () => { it("should map the value correctly", () => { @@ -87,7 +87,7 @@ describe("Epoch", () => { }); it("behavior test", () => { - const scope = new ObservableScope(); + const scope = testScope(); const s$ = new Subject(); const behavior$ = scope.behavior(s$, 0); behavior$.subscribe((value) => { @@ -115,7 +115,7 @@ describe("Reconcile", () => { it("should wait clean up before processing next", async () => { vi.useFakeTimers(); - const scope = new ObservableScope(); + const scope = testScope(); const behavior$ = new BehaviorSubject(0); const setup = vi.fn().mockImplementation(async () => await sleep(100)); @@ -149,7 +149,7 @@ describe("Reconcile", () => { it("should skip intermediates values that are not setup", async () => { vi.useFakeTimers(); - const scope = new ObservableScope(); + const scope = testScope(); const behavior$ = new BehaviorSubject(0); const setup = vi @@ -194,7 +194,7 @@ describe("Reconcile", () => { it("should wait for setup to complete before starting cleanup", async () => { vi.useFakeTimers(); - const scope = new ObservableScope(); + const scope = testScope(); const behavior$ = new BehaviorSubject(0); const setup = vi @@ -241,7 +241,7 @@ describe("Reconcile", () => { describe("behavior", () => { it("warns with the tag and nesting depth, logging each new depth once", () => { const warn = vi.spyOn(logger, "warn").mockImplementation(() => {}); - const scope = new ObservableScope(); + const scope = testScope(); const source$ = new Subject(); const behavior$ = scope.behavior(source$, 0, "tagged"); // Re-enter once on value 1 (depth 2), then a nested re-entry on value 2 @@ -267,7 +267,6 @@ describe("behavior", () => { // left stranded on the oldest. expect(behavior$.value).toBe(3); expect(seen.at(-1)).toBe(1); - scope.end(); }); it("warns when the scope is ended while a behavior is mid-delivery", () => { From b22eba7af92a03fc1bd8bd59999fca7da9b7934e Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 4 Sep 2026 07:39:58 +0200 Subject: [PATCH 044/154] Add further tests --- .../localMember/LocalMember.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 09d863aa..b270db13 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -31,6 +31,7 @@ import { flushPromises, mockConfig, mockLivekitRoom, + mockLocalParticipant, mockMuteStates, withTestScheduler, ownMemberMock, @@ -880,4 +881,99 @@ describe("LocalMembership", () => { scope.end(); }); }); + + describe("toggleScreenSharing", () => { + let originalMediaDevices: MediaDevices | undefined; + + beforeAll(() => { + mockConfig(); + // Screen sharing is only offered when getDisplayMedia is available. + originalMediaDevices = navigator.mediaDevices; + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getDisplayMedia: vi.fn() }, + }); + }); + + afterAll(() => { + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: originalMediaDevices, + }); + }); + + const createMembershipWithConnection = ( + connection: Connection | null, + ): { + scope: ObservableScope; + localMembership: ReturnType; + } => { + const scope = new ObservableScope(); + const connectionManagerData = new ConnectionManagerData(); + if (connection) connectionManagerData.add(connection, []); + const localMembership = createLocalMembership$({ + scope, + ...defaultCreateLocalMemberValues, + connectionManager: { + connectionManagerData$: constant(new Epoch(connectionManagerData)), + }, + localTransport$: new BehaviorSubject({ + advertised$: new BehaviorSubject(aTransport), + active$: new BehaviorSubject(aTransportWithSFUConfig), + }), + }); + return { scope, localMembership }; + }; + + it("surfaces a failure and clears it on dismiss", async () => { + const error = new Error("NotReadableError"); + const setScreenShareEnabled = vi.fn().mockRejectedValue(error); + const connection = { + state$: constant(ConnectionState.LivekitConnected), + transport: aTransport, + livekitRoom: mockLivekitRoom({ + localParticipant: mockLocalParticipant({ + isScreenShareEnabled: false, + setScreenShareEnabled, + }), + }), + } as unknown as Connection; + const { scope, localMembership } = + createMembershipWithConnection(connection); + await flushPromises(); + + expect(localMembership.toggleScreenSharing).not.toBeNull(); + expect(localMembership.screenShareError$.value).toBeNull(); + + localMembership.toggleScreenSharing!(); + await flushPromises(); + + expect(setScreenShareEnabled).toHaveBeenCalledWith( + true, + expect.any(Object), + undefined, + ); + expect(localMembership.screenShareError$.value).toBe(error); + + localMembership.dismissScreenShareError(); + expect(localMembership.screenShareError$.value).toBeNull(); + + scope.end(); + }); + + it("does nothing when there is no local participant", async () => { + // No connection means participant$ never resolves to a participant. + const { scope, localMembership } = + createMembershipWithConnection(null); + await flushPromises(); + + expect(localMembership.toggleScreenSharing).not.toBeNull(); + localMembership.toggleScreenSharing!(); + await flushPromises(); + + expect(localMembership.screenShareError$.value).toBeNull(); + + scope.end(); + }); + }); }); From bae8da3ddc0b6e367bbbae622b741b03b3b0c543 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 4 Sep 2026 07:44:16 +0200 Subject: [PATCH 045/154] Reformat --- src/state/CallViewModel/localMember/LocalMember.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index b270db13..aec64177 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -963,8 +963,7 @@ describe("LocalMembership", () => { it("does nothing when there is no local participant", async () => { // No connection means participant$ never resolves to a participant. - const { scope, localMembership } = - createMembershipWithConnection(null); + const { scope, localMembership } = createMembershipWithConnection(null); await flushPromises(); expect(localMembership.toggleScreenSharing).not.toBeNull(); From 70f6e785cea83520b9a06404dc2ac6120568b97b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:18:19 +0000 Subject: [PATCH 046/154] Update ghcr.io/element-hq/element-web:develop Docker digest to 724b468 (#4226) * Update ghcr.io/element-hq/element-web:develop Docker digest to a9bd290 * Fix Element Web integration tests --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Robin --- docker-compose-playwright.yml | 4 ++-- playwright/fixtures/widget-user.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index edc88cd8..6ea4b672 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -13,7 +13,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f + image: ghcr.io/element-hq/element-web:develop@sha256:a9bd290646110ee185337aa591fb35ddbd7397c5a6221efc8d12da74c28309fe element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f + image: ghcr.io/element-hq/element-web:develop@sha256:a9bd290646110ee185337aa591fb35ddbd7397c5a6221efc8d12da74c28309fe diff --git a/playwright/fixtures/widget-user.ts b/playwright/fixtures/widget-user.ts index 2c43ee1b..74d9ad8e 100644 --- a/playwright/fixtures/widget-user.ts +++ b/playwright/fixtures/widget-user.ts @@ -153,7 +153,7 @@ export const widgetTest = test.extend({ // Accept the DM invite from brooks // This how playwright record selects the DM invite in the room list - await ewPage2.getByRole("option", { name: "Open room" }).click(); + await ewPage2.getByRole("button", { name: "Open room" }).click(); await ewPage2.getByRole("button", { name: "Start chatting" }).click(); } From f5458bb03e93bda8128b96e0225c178fc614e0e6 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Fri, 4 Sep 2026 13:51:47 +0200 Subject: [PATCH 047/154] de-globalise styles --- README.md | 6 + component/build/scopeStylesToRoot.test.ts | 112 +++++++++++++ component/build/scopeStylesToRoot.ts | 161 +++++++++++++++++++ component/index.tsx | 5 +- package.json | 1 + playwright/component/component-call.spec.ts | 32 ++++ pnpm-lock.yaml | 3 + src/base.css | 13 +- src/settings/DeveloperSettingsTab.module.css | 2 +- src/settings/DeveloperSettingsTab.tsx | 4 +- vite-component-dev.config.ts | 4 + vite-component.config.ts | 133 ++++++++------- vitest.config.ts | 6 +- 13 files changed, 416 insertions(+), 66 deletions(-) create mode 100644 component/build/scopeStylesToRoot.test.ts create mode 100644 component/build/scopeStylesToRoot.ts diff --git a/README.md b/README.md index 792665f9..248609e5 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,12 @@ Element Call behaves when it does not own the page — the size it is given, whether it stays inside its container, and what it says to its host, which is logged along the bottom. +The component's stylesheet is confined to the element it is mounted in: the +build rewrites every selector so that it matches only Element Call's root or +what is inside it, with `html`, `body` and `:root` standing for that root (see +`component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles, +and Element Call brings its own fonts and design tokens along. + ### Backend A docker compose file `docker-compose-dev.yml` is provided to start the diff --git a/component/build/scopeStylesToRoot.test.ts b/component/build/scopeStylesToRoot.test.ts new file mode 100644 index 00000000..ae42062c --- /dev/null +++ b/component/build/scopeStylesToRoot.test.ts @@ -0,0 +1,112 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, it } from "vitest"; +import postcss from "postcss"; + +import { ROOT_SELECTOR, scopeStylesToRoot } from "./scopeStylesToRoot"; + +const inRoot = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`; +const isRoot = `:where(${ROOT_SELECTOR})`; + +async function scope(css: string, file = "base.css"): Promise { + const result = await postcss([scopeStylesToRoot()]).process(css, { + from: file, + }); + return result.css; +} + +describe("scopeStylesToRoot", () => { + it("makes the root stand in for the document", async () => { + expect(await scope("html { line-height: 1.15 }")).toBe( + `${isRoot} { line-height: 1.15 }`, + ); + expect(await scope("body { margin: 0 }")).toBe(`${isRoot} { margin: 0 }`); + expect(await scope(":root { --a: 1 }")).toBe(`${isRoot} { --a: 1 }`); + expect(await scope("body.no-scroll-body { position: fixed }")).toBe( + `${isRoot}.no-scroll-body { position: fixed }`, + ); + expect(await scope("body .x { color: red }")).toBe( + `${isRoot} .x { color: red }`, + ); + }); + + it("collapses selectors that all became the root", async () => { + expect(await scope("html, body, input { font: inherit }")).toBe( + `${isRoot},input${inRoot} { font: inherit }`, + ); + }); + + it("confines everything else to the root and what is inside it", async () => { + expect(await scope("h1 { margin: 0 }")).toBe(`h1${inRoot} { margin: 0 }`); + expect(await scope(".cpd-theme-dark { --a: 1 }")).toBe( + `.cpd-theme-dark${inRoot} { --a: 1 }`, + ); + expect(await scope("* { box-sizing: border-box }")).toBe( + `*${inRoot} { box-sizing: border-box }`, + ); + expect(await scope(".a > .b + .c { color: red }")).toBe( + `.a>.b+.c${inRoot} { color: red }`, + ); + }); + + it("keeps pseudo-elements last", async () => { + expect(await scope("button::-moz-focus-inner { border: 0 }")).toBe( + `button${inRoot}::-moz-focus-inner { border: 0 }`, + ); + expect(await scope(".a .b:hover::after { content: '' }")).toBe( + `.a .b:hover${inRoot}::after { content: '' }`, + ); + expect(await scope("p:first-letter { color: red }")).toBe( + `p${inRoot}:first-letter { color: red }`, + ); + }); + + it("leaves alone what already names the root", async () => { + const css = `${ROOT_SELECTOR}[data-platform="ios"] { --a: 1 }`; + expect(await scope(css)).toBe(css); + }); + + it("reaches into layers and media queries", async () => { + expect( + await scope( + "@layer normalize { h1 { margin: 0 } } @media (min-width: 1px) { p { margin: 0 } }", + ), + ).toBe( + `@layer normalize { h1${inRoot} { margin: 0 } } @media (min-width: 1px) { p${inRoot} { margin: 0 } }`, + ); + }); + + it("does not touch keyframes or nested rules", async () => { + expect( + await scope("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }"), + ).toBe("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }"); + expect( + await scope( + ".a { color: red; &:hover { color: blue } .b { color: green } }", + ), + ).toBe( + `.a${inRoot} { color: red; &:hover { color: blue } .b { color: green } }`, + ); + }); + + it("only touches the bare selectors of a CSS module", async () => { + const file = "Settings.module.css"; + expect(await scope("pre { font-size: 1px }", file)).toBe( + `pre${inRoot} { font-size: 1px }`, + ); + expect(await scope(".modal pre { font-size: 1px }", file)).toBe( + `.modal pre${inRoot} { font-size: 1px }`, + ); + expect(await scope(".box_abc12 { border: 0 }", file)).toBe( + ".box_abc12 { border: 0 }", + ); + expect(await scope(".a .b_abc12:hover { border: 0 }", file)).toBe( + ".a .b_abc12:hover { border: 0 }", + ); + }); +}); diff --git a/component/build/scopeStylesToRoot.ts b/component/build/scopeStylesToRoot.ts new file mode 100644 index 00000000..29532d53 --- /dev/null +++ b/component/build/scopeStylesToRoot.ts @@ -0,0 +1,161 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { + type AtRule, + type Container, + type Document, + type Plugin, + type Rule, +} from "postcss"; +import selectorParser, { + type Node, + type Pseudo, + type Selector, +} from "postcss-selector-parser"; + +/** + * How the stylesheets find Element Call's root element. The attribute is put + * there by `useTheme`, on the container the host gives the component. + */ +export const ROOT_SELECTOR = "[data-element-call-root]"; + +// Both are `:where()`, which has no specificity of its own, so the rules keep +// exactly the weight they had before being scoped and nothing in Element Call's +// cascade changes — only where it applies. +// +// The root, or anything inside it. Appended to the element a rule is about, +// rather than prepended to the whole selector, so that a rule about the root +// itself (its theme class, say) still matches. +const IN_ROOT = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`; +// The root itself, standing in for the document. +const IS_ROOT = `:where(${ROOT_SELECTOR})`; + +/** + * Confines a stylesheet to Element Call's root element, for the build of + * Element Call as a component. + * + * As a page of its own, Element Call can style the document: normalize.css and + * Compound speak of `html`, `body` and bare elements, and the design tokens are + * declared on `:root`. Embedded in a host, all of that would land on the host's + * document too. This rewrites every selector so that it matches only the root + * or its descendants: + * + * - `html`, `body` and `:root` become the root element, which is what stands in + * for the document inside a host. + * - Everything else keeps its selector and gains `:where([data-element-call-root], + * [data-element-call-root] *)` on the element it styles. + * - Selectors that already name the root are left alone, as are keyframe + * selectors and rules nested inside another rule, which are relative to it. + * + * CSS modules are scoped by their class names already, so only their selectors + * that would match by element alone — `pre` rather than `.pre` — are touched. + * + * The root's fonts and design tokens are still inherited by everything inside + * it, the way they were from `body` and `:root`, and `@font-face` declarations + * stay global, which they are by nature. + */ +export function scopeStylesToRoot(): Plugin { + return { + postcssPlugin: "element-call-scope-styles-to-root", + Once(root) { + const isModule = + root.source?.input.file?.endsWith(".module.css") ?? false; + root.walkRules((rule) => { + if (isRelative(rule)) return; + rule.selector = (isModule ? scopeBare : scopeAll).processSync( + rule.selector, + { lossless: false }, + ); + }); + }, + }; +} + +/** Whether a rule's selectors are relative to something other than the document. */ +function isRelative(rule: Rule): boolean { + let parent: Container | Document | undefined = rule.parent; + while (parent !== undefined) { + if (parent.type === "rule") return true; + if (parent.type === "atrule") { + const { name } = parent as AtRule; + if (name.endsWith("keyframes") || name === "page") return true; + } + parent = parent.parent; + } + return false; +} + +const processor = (isModule: boolean): ReturnType => + selectorParser((selectors) => { + selectors.each((selector) => { + scopeSelector(selector, isModule); + }); + // Mapping `html, body` onto the root leaves the same selector twice + const seen = new Set(); + selectors.each((selector) => { + const text = String(selector).trim(); + if (seen.has(text)) selector.remove(); + else seen.add(text); + }); + }); + +// Everything, for stylesheets that speak of the document; only what a class +// does not already confine, for CSS modules +const scopeAll = processor(false); +const scopeBare = processor(true); + +function scopeSelector(selector: Selector, isModule: boolean): void { + if (String(selector).includes(ROOT_SELECTOR)) return; + + const compounds = splitCompounds(selector); + if (compounds.length === 0) return; + + // Something said of the document is said of the root instead + const document = compounds[0].find(isDocumentSelector); + if (document !== undefined) { + document.replaceWith(pseudo(IS_ROOT)); + return; + } + + const subject = compounds.at(-1)!; + if (isModule && subject.some((node) => node.type === "class")) return; + + // Pseudo-elements have to come last in a compound selector + const pseudoElement = subject.find(isPseudoElement); + if (pseudoElement === undefined) selector.append(pseudo(IN_ROOT)); + else selector.insertBefore(pseudoElement, pseudo(IN_ROOT)); +} + +/** The compound selectors making up a complex selector, in order. */ +function splitCompounds(selector: Selector): Node[][] { + const compounds: Node[][] = [[]]; + for (const node of selector.nodes) { + if (node.type === "combinator") compounds.push([]); + else if (node.type !== "comment") compounds.at(-1)!.push(node); + } + return compounds.filter((compound) => compound.length > 0); +} + +function isDocumentSelector(node: Node): boolean { + return ( + (node.type === "tag" && (node.value === "html" || node.value === "body")) || + (node.type === "pseudo" && node.value === ":root") + ); +} + +function isPseudoElement(node: Node): node is Pseudo { + if (node.type !== "pseudo") return false; + return ( + node.value.startsWith("::") || + [":before", ":after", ":first-line", ":first-letter"].includes(node.value) + ); +} + +function pseudo(text: string): Pseudo { + return selectorParser().astSync(text).nodes[0].nodes[0].clone() as Pseudo; +} diff --git a/component/index.tsx b/component/index.tsx index 55e2f996..84a18353 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -18,7 +18,10 @@ Please see LICENSE in the repository root for full details. */ // The design tokens, fonts and element defaults every Element Call stylesheet -// builds on. +// builds on. Written for a page, they speak of `html`, `body` and bare +// elements; the component build confines them, and every other stylesheet in +// this bundle, to the root element below (see build/scopeStylesToRoot.ts), so +// that the host's document is left as it was. // // Where these land relative to the component stylesheets is the bundler's // choice — the standalone app puts them first, this build puts them in the diff --git a/package.json b/package.json index cf1e1f71..471cd28b 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "pako": "^2.0.4", "postcss": "^8.4.41", "postcss-preset-env": "^10.0.0", + "postcss-selector-parser": "^7.1.1", "posthog-js": "1.408.2", "qrcode": "^1.5.4", "react": "19", diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index 233b826a..d679fbb2 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -77,6 +77,38 @@ test("keeps its modals inside the container it was given", async ({ page }) => { ); }); +test("leaves the host's own page unstyled", async ({ page }) => { + const { username, roomId } = await createUserAndRoom("hoststyles"); + const panes = await startHarness(page, username, roomId); + await expect(panes.first().getByTestId("lobby_joinCall")).toBeVisible({ + timeout: 60_000, + }); + + // Element Call's stylesheet is written for a page of its own: normalize.css + // gives `html` a line height, Compound gives `body` its font and feature + // settings, and the design tokens live on `:root`. None of that may reach the + // host's document — the harness sets none of these itself, so anything other + // than the browser's defaults here came from us. + const host = await page.evaluate(() => { + const html = getComputedStyle(document.documentElement); + const body = getComputedStyle(document.body); + return { + lineHeight: html.lineHeight, + fontFeatureSettings: body.fontFeatureSettings, + token: html.getPropertyValue("--cpd-color-text-primary"), + }; + }); + expect(host).toEqual({ + lineHeight: "normal", + fontFeatureSettings: "normal", + token: "", + }); + + // While inside the container, the same rules do apply + const root = panes.first().locator("[data-element-call-root]"); + await expect(root).toHaveCSS("font-feature-settings", /"kern"/); +}); + test("tells its host what it is doing", async ({ page }) => { const { username, roomId } = await createUserAndRoom("hostbridge"); const panes = await startHarness(page, username, roomId); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cfb3ccd..b50a25f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,9 @@ importers: postcss-preset-env: specifier: ^10.0.0 version: 10.6.1(postcss@8.5.25) + postcss-selector-parser: + specifier: ^7.1.1 + version: 7.1.1 posthog-js: specifier: 1.408.2 version: 1.408.2 diff --git a/src/base.css b/src/base.css index 0d424e64..ae09f492 100644 --- a/src/base.css +++ b/src/base.css @@ -10,11 +10,14 @@ and element defaults its own stylesheets build on top of. Split out from index.css so that Element Call embedded in a host application can have these without also being given the standalone page's layout, which -would style the host's own document. What remains here does still reach outside -Element Call's container — normalize.css and the typography below use bare -element selectors, and the custom properties are declared on `:root` — so a -host gets those too. Narrowing them needs a real host to check against, so it -waits for the Element Web integration rather than being guessed at here. +would style the host's own document. What remains here still speaks of the +document — normalize.css and the typography below use bare element selectors, +and the custom properties are declared on `:root` — which is right for the +page, and is why the component build rewrites it: there every selector is +confined to Element Call's root element (see component/build/scopeStylesToRoot.ts), +with `html`, `body` and `:root` becoming that element. Nothing needs to be +written differently here for that to work, but nothing here may rely on +reaching the host's document either. Nothing here should depend on where it lands relative to Element Call's component stylesheets: the bundler decides that, and it decides differently for diff --git a/src/settings/DeveloperSettingsTab.module.css b/src/settings/DeveloperSettingsTab.module.css index 29f4211b..369bec3b 100644 --- a/src/settings/DeveloperSettingsTab.module.css +++ b/src/settings/DeveloperSettingsTab.module.css @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -pre { +.pre { font-size: var(--font-size-micro); } diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 25b3c81c..ec5bf7b0 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -680,9 +680,9 @@ export const DeveloperSettingsTab: FC = ({

    {t("developer_mode.environment_variables")}

    -
    {JSON.stringify(env, null, 2)}
    +
    {JSON.stringify(env, null, 2)}

    {t("developer_mode.url_params")}

    -
    {JSON.stringify(urlParams, null, 2)}
    +
    {JSON.stringify(urlParams, null, 2)}
    ); }; diff --git a/vite-component-dev.config.ts b/vite-component-dev.config.ts index 61cb5e51..2e678382 100644 --- a/vite-component-dev.config.ts +++ b/vite-component-dev.config.ts @@ -10,6 +10,7 @@ import { realpathSync } from "node:fs"; import * as fs from "node:fs"; import { vitePluginsConfig } from "./vite.config"; +import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot"; // Serves the harness under `component/dev`, which embeds Element Call as a // component the way a host application would. Development only: this is not @@ -35,6 +36,9 @@ export default defineConfig(({ mode }) => { return { ...vitePluginsConfig({ mode, html: false }), root: "component/dev", + // The same scoping the library build applies, so the harness shows what a + // host will get — including whether its own page is left alone + css: { postcss: { plugins: [scopeStylesToRoot()] } }, // So that the harness can read the same config.json the standalone app // does, if the developer has written one publicDir: "../../public", diff --git a/vite-component.config.ts b/vite-component.config.ts index 17f62562..127f599f 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -8,67 +8,88 @@ Please see LICENSE in the repository root for full details. import { defineConfig } from "vite"; import { vitePluginsConfig } from "./vite.config"; +import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot"; // Config for Element Call as a React component, to be imported by an // application embedding it rather than served as a page of its own. // // Deliberately not built on top of the full app's config, which exists to // produce a page and brings an HTML entry point along with it. -export default defineConfig(({ mode }) => ({ - ...vitePluginsConfig({ mode, html: false }), - // A library has no public directory to serve. Without this the build copies - // whatever is in `public` — including the developer's own config.json, which - // is not in the repository — into the output we would publish. - publicDir: false, - build: { - minify: mode === "production", - sourcemap: true, - // One stylesheet rather than one per chunk, so a host has a single file to - // include - cssCodeSplit: false, - lib: { - formats: ["es" as const], - entry: "./component/index.tsx", - fileName: "element-call", +export default defineConfig(({ mode }) => { + const base = vitePluginsConfig({ mode, html: false }); + return { + ...base, + resolve: { + ...base.resolve, + alias: { + ...base.resolve?.alias, + // react-i18next depends on the CommonJS `use-sync-external-store/shim`, whose + // `require("react")` cannot be bundled against an external React: rolldown leaves a + // `require` shim that throws in the browser. React 18+ provides `useSyncExternalStore` + // itself, so point the shim at React. + "use-sync-external-store/shim": "react", + }, }, - rollupOptions: { - // The host already has these, and a second copy of any of them does not - // merely bloat the bundle: React would hold two sets of hooks, and the - // Matrix client would run two sync loops. - // - // Every subpath has to be named. Element Call reaches most of the Matrix - // SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not - // catch those — while the pattern and callback forms of this option are - // silently ignored by the bundler, so they cannot be used to cover them. - // `pnpm lint:externals` reads this list and fails if the source imports - // one of these packages by a path it does not name; a few entries below - // are there only because the standalone app imports them, which costs - // nothing. - external: [ - "react", - "react/jsx-runtime", - "react-dom", - "react-dom/client", - "livekit-client", - "matrix-js-sdk", - "matrix-js-sdk/lib/browser-index", - "matrix-js-sdk/lib/client", - "matrix-js-sdk/lib/crypto-api", - "matrix-js-sdk/lib/indexeddb-worker", - "matrix-js-sdk/lib/logger", - "matrix-js-sdk/lib/matrix", - "matrix-js-sdk/lib/matrixrtc", - "matrix-js-sdk/lib/matrixrtc/EncryptionManager", - "matrix-js-sdk/lib/matrixrtc/IKeyTransport", - "matrix-js-sdk/lib/matrixrtc/IMembershipManager", - "matrix-js-sdk/lib/models/relations-container", - "matrix-js-sdk/lib/models/room", - "matrix-js-sdk/lib/models/typed-event-emitter", - "matrix-js-sdk/lib/randomstring", - "matrix-js-sdk/lib/sync", - "matrix-js-sdk/lib/types", - "matrix-js-sdk/lib/utils", - ], + // A library has no public directory to serve. Without this the build copies + // whatever is in `public` — including the developer's own config.json, which + // is not in the repository — into the output we would publish. + publicDir: false, + // A host's document is not ours to style: everything in the stylesheet is + // confined to the element Element Call is mounted in + css: { postcss: { plugins: [scopeStylesToRoot()] } }, + build: { + minify: mode === "production", + sourcemap: true, + // One stylesheet rather than one per chunk, so a host has a single file to + // include + cssCodeSplit: false, + lib: { + formats: ["es" as const], + entry: "./component/index.tsx", + fileName: "element-call", + }, + rollupOptions: { + // The host already has these, and a second copy of any of them does not + // merely bloat the bundle: React would hold two sets of hooks, and the + // Matrix client would run two sync loops. + // + // Every subpath has to be named. Element Call reaches most of the Matrix + // SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not + // catch those — while the pattern and callback forms of this option are + // silently ignored by the bundler, so they cannot be used to cover them. + // `pnpm lint:externals` reads this list and fails if the source imports + // one of these packages by a path it does not name; a few entries below + // are there only because the standalone app imports them, which costs + // nothing. + external: [ + "react", + "react/jsx-runtime", + // Emitted by the React Compiler for every compiled component; part of React, so it must be + // the host's copy too (bundled, its CommonJS `require("react")` throws in the browser). + "react/compiler-runtime", + "react-dom", + "react-dom/client", + "livekit-client", + "matrix-js-sdk", + "matrix-js-sdk/lib/browser-index", + "matrix-js-sdk/lib/client", + "matrix-js-sdk/lib/crypto-api", + "matrix-js-sdk/lib/indexeddb-worker", + "matrix-js-sdk/lib/logger", + "matrix-js-sdk/lib/matrix", + "matrix-js-sdk/lib/matrixrtc", + "matrix-js-sdk/lib/matrixrtc/EncryptionManager", + "matrix-js-sdk/lib/matrixrtc/IKeyTransport", + "matrix-js-sdk/lib/matrixrtc/IMembershipManager", + "matrix-js-sdk/lib/models/relations-container", + "matrix-js-sdk/lib/models/room", + "matrix-js-sdk/lib/models/typed-event-emitter", + "matrix-js-sdk/lib/randomstring", + "matrix-js-sdk/lib/sync", + "matrix-js-sdk/lib/types", + "matrix-js-sdk/lib/utils", + ], + }, }, - }, -})); + }; +}); diff --git a/vitest.config.ts b/vitest.config.ts index c5e908e4..81519325 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,7 +21,11 @@ export default defineConfig((configEnv) => css: { include: /.+/ }, setupFiles: ["src/vitest.setup.ts"], environment: "jsdom", - include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + include: [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "component/**/*.test.ts", + ], }, }, { From 38c28a1c8ffb8c7f26706e22f8845808de0ad311 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Fri, 4 Sep 2026 14:16:40 +0200 Subject: [PATCH 048/154] scaling based on component size instead webview size --- README.md | 4 + playwright/component/component-call.spec.ts | 33 ++++++ sdk/main.ts | 3 + src/room/InCallView.test.tsx | 70 +++++++++++- src/room/InCallView.tsx | 10 ++ .../__snapshots__/InCallView.test.tsx.snap | 1 + src/state/CallViewModel/CallViewModel.ts | 30 ++--- src/utils/elementSize.test.ts | 104 ++++++++++++++++++ src/utils/elementSize.ts | 42 +++++++ src/utils/test-viewmodel.ts | 1 + src/vitest.setup.ts | 9 ++ 11 files changed, 293 insertions(+), 14 deletions(-) create mode 100644 src/utils/elementSize.test.ts create mode 100644 src/utils/elementSize.ts diff --git a/README.md b/README.md index 248609e5..48b9bbc8 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,10 @@ Element Call behaves when it does not own the page — the size it is given, whether it stays inside its container, and what it says to its host, which is logged along the bottom. +The call lays itself out for the size of the element it is mounted in, not the +window: a host that shrinks the container to a corner of its page gets the +picture-in-picture layout, just as a host that shrank the whole iframe used to. + The component's stylesheet is confined to the element it is mounted in: the build rewrites every selector so that it matches only Element Call's root or what is inside it, with `html`, `body` and `:root` standing for that root (see diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index d679fbb2..97a00267 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -132,3 +132,36 @@ test("tells its host what it is doing", async ({ page }) => { timeout: 30_000, }); }); + +test("lays itself out for the space it is given, not the page", async ({ + page, +}) => { + const { username, roomId } = await createUserAndRoom("containersize"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + const container = pane.getByTestId("call-container"); + const call = pane.locator("[data-layout]"); + + await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); + await expect(call).toBeVisible({ timeout: 60_000 }); + await expect(call).not.toHaveAttribute("data-layout", "pip"); + + // As a widget, Element Call's container and its window were one and the same: + // a host wanting a picture-in-picture made the iframe small, and Element Call + // saw the window shrink. A component gets no such signal from the window, + // which stays as large as it ever was; only the container changes. + const resize = async (width: number, height: number): Promise => + container.evaluate( + (element, size) => { + element.style.width = `${size.width}px`; + element.style.height = `${size.height}px`; + }, + { width, height }, + ); + + await resize(300, 300); + await expect(call).toHaveAttribute("data-layout", "pip"); + + await resize(900, 700); + await expect(call).not.toHaveAttribute("data-layout", "pip"); +}); diff --git a/sdk/main.ts b/sdk/main.ts index 6347d142..6cd37032 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -60,6 +60,7 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { initializeWidget } from "../src/widget"; import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection"; import { createWidgetHostBridge } from "../src/HostBridge"; +import { observeElementSize$ } from "../src/utils/elementSize"; interface MatrixRTCSdk { /** @@ -151,6 +152,8 @@ export async function createMatrixRTCSdk( ...callViewModelOptionsFromParams(urlParams), encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, hostBridge, + // The SDK owns its page, so the body is the space it has + windowSize$: scope.behavior(observeElementSize$(document.body)), }, of({}), of({}), diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index 3113c072..357bc186 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -14,7 +14,7 @@ import { type MockedFunction, vi, } from "vitest"; -import { render, type RenderResult } from "@testing-library/react"; +import { act, render, type RenderResult } from "@testing-library/react"; import { type LocalParticipant } from "livekit-client"; import { BehaviorSubject, of } from "rxjs"; import { BrowserRouter } from "react-router-dom"; @@ -51,6 +51,7 @@ import { AppBar } from "../AppBar"; import { type MatrixInfo } from "./VideoPreview"; import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { initializeWidget } from "../widget"; +import { RootElementProvider } from "../RootElementContext"; initializeWidget(); vi.hoisted( @@ -263,4 +264,71 @@ describe("ActiveCall", () => { // Rendering at all proves ActiveCall created all of its view models expect(await findByTestId("incall_leave")).toBeVisible(); }); + + it("lays the call out for the size of its root element", async () => { + // jsdom has no layout and no ResizeObserver: the root reports whatever + // size we say, and the observer notifies whenever we tell it to + let size = { width: 1000, height: 800 }; + const root = document.createElement("div"); + Object.defineProperty(root, "clientWidth", { get: () => size.width }); + Object.defineProperty(root, "clientHeight", { get: () => size.height }); + document.body.appendChild(root); + + const observers: (() => void)[] = []; + const originalResizeObserver = window.ResizeObserver; + window.ResizeObserver = class { + public constructor(private readonly callback: ResizeObserverCallback) {} + public observe(): void { + observers.push(() => this.callback([], this as ResizeObserver)); + } + public unobserve(): void {} + public disconnect(): void {} + } as unknown as typeof ResizeObserver; + + try { + const mediaDevices = mockMediaDevices({}); + const { rtcSession, matrixRoom } = getBasicRTCSession([local, alice]); + const { findByTestId, container } = render( + + + + + + + {}} + /> + + + + + + , + { container: root }, + ); + await findByTestId("incall_leave"); + const call = container.querySelector("[data-layout]")!; + expect(call.getAttribute("data-layout")).not.toBe("pip"); + + // The host shrinks the container to a corner of its page. The window has + // not changed at all — what matters is the element we were given. + size = { width: 300, height: 300 }; + act(() => observers.forEach((notify) => notify())); + expect(call.getAttribute("data-layout")).toBe("pip"); + + size = { width: 1000, height: 800 }; + act(() => observers.forEach((notify) => notify())); + expect(call.getAttribute("data-layout")).not.toBe("pip"); + } finally { + window.ResizeObserver = originalResizeObserver; + root.remove(); + } + }); }); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 97fb7543..e2df7529 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -29,6 +29,8 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { HeaderStyle, useUrlParams } from "../UrlParams"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { useHostBridge } from "../HostBridge.ts"; +import { useRootElement } from "../RootElementContext"; +import { observeElementSize$ } from "../utils/elementSize"; import styles from "./InCallView.module.css"; import { GridTile } from "../tile/GridTile"; import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal"; @@ -120,6 +122,9 @@ export const ActiveCall: FC = (props) => { const hostBridge = useHostBridge(); const mediaDevices = useMediaDevices(); const trackProcessorState$ = useTrackProcessorObservable$(); + // The element we have to draw the call in: the page, or the container a host + // gave us. Its size, not the window's, decides how the call is laid out. + const rootElement = useRootElement(); useEffect(() => { rootLogger.info("START CALL VIEW SCOPE"); const scope = new ObservableScope(); @@ -140,6 +145,7 @@ export const ActiveCall: FC = (props) => { autoLeaveWhenOthersLeft, waitForCallPickup: waitForCallPickup && sendNotificationType === "ring", matrixRTCMode$: matrixRTCModeSetting.value$, + windowSize$: scope.behavior(observeElementSize$(rootElement)), }, reactionsReader.raisedHands$, reactionsReader.reactions$, @@ -165,6 +171,7 @@ export const ActiveCall: FC = (props) => { mediaDevices, trackProcessorState$, props.client, + rootElement, ]); useEffect(() => { @@ -625,6 +632,9 @@ export const InCallView: FC = ({ [styles.overflowing]: overflowing, })} ref={containerRef} + // Which layout the call has settled on, for tests and for anyone + // wondering why the call looks the way it does at the size it was given + data-layout={layout.type} onPointerUp={onViewPointerUp} onPointerMove={onPointerMove} onPointerOut={onPointerOut} diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 984e2b12..cdf5d17b 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -4,6 +4,7 @@ exports[`InCallView > rendering > renders 1`] = `
    LivekitRoom; /** Optional behavior overriding the local connection state, mainly for testing purposes. */ connectionState$?: Behavior; - /** Optional behavior overriding the computed window size, mainly for testing purposes. */ - windowSize$?: Behavior<{ width: number; height: number }>; + /** + * The size of the space the call is drawn in: the page when Element Call + * owns it, or the container a host mounted it in when it is a component. + * The layout — whether the call is shown full size, flat, narrow or as a + * picture-in-picture — follows this rather than the size of the window, so + * that a component shrunk by its host adapts even though the window has not + * changed. + */ + windowSize$: Behavior<{ width: number; height: number }>; /** Optional value overriding the local transport, for testing purposes. */ localTransport?: LocalTransport; /** Optional value overriding the connection factory, for testing purposes. */ @@ -263,6 +270,11 @@ const smallMobileCallThreshold = 3; // with the interface const showFooterMs = 4000; +/** + * The general shape of the space the call is drawn in. Called a window because + * that is what it is in the standalone app; for a component it is the container + * the host gave us, which may be a small corner of a large window. + */ export type WindowMode = "normal" | "narrow" | "flat" | "pip"; interface LayoutScanState { @@ -1097,18 +1109,10 @@ export function createCallViewModel$( const pipEnabled$ = scope.behavior(setPipEnabled$, false); - const windowSize$ = - options.windowSize$ ?? - scope.behavior<{ width: number; height: number }>( - fromEvent(window, "resize").pipe( - startWith(null), - map(() => ({ width: window.innerWidth, height: window.innerHeight })), - ), - ); - - // A guess at what the window's mode should be based on its size and shape. + // A guess at what the window's mode should be based on the size and shape of + // the space we have to draw in. const naturalWindowMode$ = scope.behavior( - windowSize$.pipe( + options.windowSize$.pipe( map(({ width, height }) => { if (height <= 400 && width <= 340) return "pip"; // Our layouts for flat windows are better at adapting to a small width diff --git a/src/utils/elementSize.test.ts b/src/utils/elementSize.test.ts new file mode 100644 index 00000000..854da6f1 --- /dev/null +++ b/src/utils/elementSize.test.ts @@ -0,0 +1,104 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { observeElementSize$ } from "./elementSize"; + +/** + * A ResizeObserver whose notifications the test triggers itself, and which + * keeps track of what it is watching. jsdom does not ship one. + */ +class MockResizeObserver { + public static instances: MockResizeObserver[] = []; + public observed: Element[] = []; + public disconnected = false; + + public constructor(private readonly callback: ResizeObserverCallback) { + MockResizeObserver.instances.push(this); + } + + public observe(element: Element): void { + this.observed.push(element); + // Real observers report the initial size once observation starts + this.fire(); + } + + public unobserve(): void {} + + public disconnect(): void { + this.disconnected = true; + } + + public fire(): void { + this.callback([], this as unknown as ResizeObserver); + } +} + +describe("observeElementSize$", () => { + const originalResizeObserver = window.ResizeObserver; + let element: HTMLDivElement; + let size: { width: number; height: number }; + + beforeEach(() => { + MockResizeObserver.instances = []; + window.ResizeObserver = + MockResizeObserver as unknown as typeof ResizeObserver; + // jsdom does no layout, so the element reports whatever we say it does + size = { width: 800, height: 600 }; + element = document.createElement("div"); + Object.defineProperty(element, "clientWidth", { get: () => size.width }); + Object.defineProperty(element, "clientHeight", { get: () => size.height }); + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + }); + + it("emits the current size synchronously on subscription", () => { + const next = vi.fn(); + observeElementSize$(element).subscribe(next); + expect(next).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledWith({ width: 800, height: 600 }); + expect(MockResizeObserver.instances[0].observed).toEqual([element]); + }); + + it("emits again whenever the element is resized", () => { + const next = vi.fn(); + observeElementSize$(element).subscribe(next); + const [observer] = MockResizeObserver.instances; + + size = { width: 300, height: 300 }; + observer.fire(); + size = { width: 1000, height: 700 }; + observer.fire(); + + expect(next.mock.calls.map(([s]) => s)).toEqual([ + { width: 800, height: 600 }, + { width: 300, height: 300 }, + { width: 1000, height: 700 }, + ]); + }); + + it("does not repeat a size that has not changed", () => { + const next = vi.fn(); + observeElementSize$(element).subscribe(next); + // The initial report the observer makes on observe() carried the same size + // as the synchronous measurement, and so did not count + expect(next).toHaveBeenCalledTimes(1); + MockResizeObserver.instances[0].fire(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("stops observing when unsubscribed", () => { + const subscription = observeElementSize$(element).subscribe(); + const [observer] = MockResizeObserver.instances; + expect(observer.disconnected).toBe(false); + subscription.unsubscribe(); + expect(observer.disconnected).toBe(true); + }); +}); diff --git a/src/utils/elementSize.ts b/src/utils/elementSize.ts new file mode 100644 index 00000000..c2d5e921 --- /dev/null +++ b/src/utils/elementSize.ts @@ -0,0 +1,42 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { Observable, distinctUntilChanged } from "rxjs"; + +export interface ElementSize { + width: number; + height: number; +} + +/** + * Observes the size of an element, starting with the size it currently has and + * following it through every change for as long as the subscription lasts. + * + * Measured with `clientWidth`/`clientHeight`, so borders, scrollbars and any + * transforms a host might animate the element with are left out: this is the + * space there is to draw in, not where the element happens to be painted. + */ +export function observeElementSize$(element: Element): Observable { + return new Observable((subscriber) => { + const measure = (): void => + subscriber.next({ + width: element.clientWidth, + height: element.clientHeight, + }); + // Synchronously, so that a Behavior built on this has an initial value + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return (): void => observer.disconnect(); + }).pipe( + // A ResizeObserver reports once on observe(), which repeats the first + // measurement above, and again for changes to dimensions we do not read + distinctUntilChanged( + (a, b) => a.width === b.width && a.height === b.height, + ), + ); +} diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index 8e88e720..c1feb03e 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -176,6 +176,7 @@ export function getBasicCallViewModelEnvironment( }), connectionState$: constant(ConnectionState.Connected), matrixRTCMode$: constant(MatrixRTCMode.Compatibility), + windowSize$: constant({ width: 1000, height: 800 }), ...callViewModelOptions, }, handRaisedSubject$, diff --git a/src/vitest.setup.ts b/src/vitest.setup.ts index 00d289b5..373f08c9 100644 --- a/src/vitest.setup.ts +++ b/src/vitest.setup.ts @@ -53,6 +53,15 @@ window.matchMedia = global.matchMedia = (): MediaQueryList => removeEventListener: () => {}, }) as Partial as MediaQueryList; +// jsdom does no layout and has no ResizeObserver. The call view observes the +// size of its root element; this one reports nothing, so that element stays at +// whatever size jsdom says it is (zero) unless a test says otherwise. +window.ResizeObserver ??= class ResizeObserver { + public observe(): void {} + public unobserve(): void {} + public disconnect(): void {} +}; + const storage: Record = {}; const localStoragePolyfill = { getItem(key: string) { From 218ee46b7d86a01b3344c05f4309af06406d2af1 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Fri, 4 Sep 2026 14:35:44 +0200 Subject: [PATCH 049/154] Make the component installable as a git dependency `component/package.json` describes the built component as a package (`@element-hq/element-call-component`: entry, stylesheet subpath, types, peer dependencies) so that a host can depend on `github:element-hq/element-call#&path:/component`. Its `prepare` script builds on install, since nothing is published yet. For that the component build now lands in `component/dist` instead of the repository's `dist`, and `pnpm build:component` also emits the type declarations (`component/tsconfig.build.json`, `build:component:types`), which previously had to be produced by hand. Co-Authored-By: Claude Fable 5.1 --- README.md | 17 +++++++++++++- component/package.json | 42 +++++++++++++++++++++++++++++++++++ component/pnpm-lock.yaml | 9 ++++++++ component/pnpm-workspace.yaml | 14 ++++++++++++ component/tsconfig.build.json | 20 +++++++++++++++++ package.json | 6 +++-- vite-component.config.ts | 4 ++++ 7 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 component/package.json create mode 100644 component/pnpm-lock.yaml create mode 100644 component/pnpm-workspace.yaml create mode 100644 component/tsconfig.build.json diff --git a/README.md b/README.md index 48b9bbc8..0732ff41 100644 --- a/README.md +++ b/README.md @@ -217,7 +217,8 @@ See also: Element Call can also be embedded directly into another React application rather than being loaded in an iframe as a widget. `pnpm build:component` -builds it as a library, and +builds it as a library into `component/dist` (the bundle, its stylesheet and +type declarations), and ```sh pnpm dev:component @@ -240,6 +241,20 @@ what is inside it, with `html`, `body` and `:root` standing for that root (see `component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles, and Element Call brings its own fonts and design tokens along. +The package is not published yet. A host installs it as a git dependency on the +`component` directory of this repository, + +```json +"@element-hq/element-call-component": "github:element-hq/element-call#main&path:/component" +``` + +whose `prepare` script runs the build on install (the host's pnpm has to allow +that: `allowBuilds` in its `pnpm-workspace.yaml`). It imports the component from +`@element-hq/element-call-component` and the stylesheet from +`@element-hq/element-call-component/style.css`, and has to provide `react`, +`react-dom`, `matrix-js-sdk` and `livekit-client` itself, since the bundle leaves +them external. + ### Backend A docker compose file `docker-compose-dev.yml` is provided to start the diff --git a/component/package.json b/component/package.json new file mode 100644 index 00000000..9ed81e02 --- /dev/null +++ b/component/package.json @@ -0,0 +1,42 @@ +{ + "name": "@element-hq/element-call-component", + "version": "0.0.0", + "description": "Element Call as a React component. Consumed straight from the repository as a git dependency (github:element-hq/element-call#&path:/component): the host's package manager runs `prepare`, which builds `dist/`.", + "license": "SEE LICENSE IN ../README.md", + "repository": { + "type": "git", + "url": "https://github.com/element-hq/element-call", + "directory": "component" + }, + "type": "module", + "devEngines": { + "packageManager": { + "name": "pnpm" + } + }, + "files": [ + "dist" + ], + "main": "./dist/element-call.js", + "module": "./dist/element-call.js", + "types": "./dist/types/component/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/component/index.d.ts", + "default": "./dist/element-call.js" + }, + "./style.css": "./dist/element-call.css" + }, + "sideEffects": [ + "*.css" + ], + "scripts": { + "prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:component" + }, + "peerDependencies": { + "livekit-client": "^2.18.1", + "matrix-js-sdk": "*", + "react": "^19", + "react-dom": "^19" + } +} diff --git a/component/pnpm-lock.yaml b/component/pnpm-lock.yaml new file mode 100644 index 00000000..490c2e4f --- /dev/null +++ b/component/pnpm-lock.yaml @@ -0,0 +1,9 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +importers: + + .: {} diff --git a/component/pnpm-workspace.yaml b/component/pnpm-workspace.yaml new file mode 100644 index 00000000..d6c26ac6 --- /dev/null +++ b/component/pnpm-workspace.yaml @@ -0,0 +1,14 @@ +# Makes `component/` a pnpm project of its own rather than a directory inside +# the repository's workspace. That matters when a host installs the component +# straight from this repository (`github:element-hq/element-call#…&path:/component`): +# pnpm prepares such a dependency by running `pnpm install` in this directory, +# and only a project root gets its `prepare` script (see package.json) run, which +# is what builds `dist/`. Inside the repository's own workspace the install +# would silently target the repository instead and build nothing. +# +# Consequence for development: pnpm commands run from within this directory see +# this project, not the repository; run them from the repository root. + +# Nothing to install here: the peers in package.json are the host's, and the +# build runs against the repository's own node_modules (see `prepare`). +autoInstallPeers: false diff --git a/component/tsconfig.build.json b/component/tsconfig.build.json new file mode 100644 index 00000000..18af65d8 --- /dev/null +++ b/component/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + // Declaration output for the component package (`pnpm build:component:types`). + // The root tsconfig only type-checks; this one emits `.d.ts` files, and nothing + // else, for everything the component's entry point reaches. The layout under + // `dist/types` mirrors the repository (`component/index.d.ts`, `src/…`), which + // is what `package.json` points its `types` at. + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": false, + "rootDir": "..", + "outDir": "./dist/types" + }, + // The entry point, plus the ambient declarations (CSS modules, `?react` SVGs, + // `import.meta.env`, …) that the sources it reaches rely on. + "include": ["./index.tsx", "../src/@types/*.d.ts"], + "exclude": [] +} diff --git a/package.json b/package.json index 471cd28b..fc556093 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,11 @@ "build:sdk:development": "pnpm build:sdk --mode development", "build:sdk": "pnpm build:full --config vite-sdk.config.js", "build:sdk:production": "pnpm build:sdk", - "build:component": "pnpm build:full --config vite-component.config.js", + "build:component": "pnpm build:component:js && pnpm build:component:types", + "build:component:js": "pnpm build:full --config vite-component.config.js", + "build:component:types": "tsc -p component/tsconfig.build.json", "build:component:production": "pnpm build:component", - "build:component:development": "pnpm build:component --mode development", + "build:component:development": "pnpm build:component:js --mode development && pnpm build:component:types", "serve": "vite preview", "format": "oxfmt", "format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc", diff --git a/vite-component.config.ts b/vite-component.config.ts index 127f599f..ae0ac150 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -38,6 +38,10 @@ export default defineConfig(({ mode }) => { // confined to the element Element Call is mounted in css: { postcss: { plugins: [scopeStylesToRoot()] } }, build: { + // Into the package directory, so that `component/package.json` describes + // what sits next to it and the directory can be installed as a package + // (see its `files` and `exports`). + outDir: "component/dist", minify: mode === "production", sourcemap: true, // One stylesheet rather than one per chunk, so a host has a single file to From 210d94af18f3bbfd7211c27ffe9b2ffce7d3eae6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:55:22 +0000 Subject: [PATCH 050/154] Update ghcr.io/element-hq/element-web:develop Docker digest to 3221e92 (#4237) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 6ea4b672..6028982e 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -13,7 +13,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:a9bd290646110ee185337aa591fb35ddbd7397c5a6221efc8d12da74c28309fe + image: ghcr.io/element-hq/element-web:develop@sha256:3221e922389406f62e896d452891a9eae59d34c8c97c7adb65894873087e883c element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:a9bd290646110ee185337aa591fb35ddbd7397c5a6221efc8d12da74c28309fe + image: ghcr.io/element-hq/element-web:develop@sha256:3221e922389406f62e896d452891a9eae59d34c8c97c7adb65894873087e883c From a03f23e7206fa7d45911ec3da6af988452804614 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 02:02:35 +0000 Subject: [PATCH 051/154] Update ghcr.io/element-hq/element-web:develop Docker digest to a4e9c8c (#4238) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 6028982e..54eb8104 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -13,7 +13,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:3221e922389406f62e896d452891a9eae59d34c8c97c7adb65894873087e883c + image: ghcr.io/element-hq/element-web:develop@sha256:a4e9c8cb178fcabd9495da86fab32d218550747c2ef6f9baf0afca2be3436820 element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:3221e922389406f62e896d452891a9eae59d34c8c97c7adb65894873087e883c + image: ghcr.io/element-hq/element-web:develop@sha256:a4e9c8cb178fcabd9495da86fab32d218550747c2ef6f9baf0afca2be3436820 From d204b36b5574b8e4b03d811de2cf48c037b5cf32 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:56:21 +0000 Subject: [PATCH 052/154] Update dependency livekit-client to v2.22.2 --- pnpm-lock.yaml | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cfb3ccd..0d5550f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,13 +39,13 @@ importers: version: 11.7.12 '@livekit/components-core': specifier: ^0.12.0 - version: 0.12.15(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) + version: 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) '@livekit/components-react': specifier: ^2.0.0 - version: 2.9.24(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1) + version: 2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1) '@livekit/track-processors': specifier: ^0.7.1 - version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22)) + version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22)) '@mediapipe/tasks-vision': specifier: ^0.10.18 version: 0.10.35 @@ -183,7 +183,7 @@ importers: version: 5.88.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(typescript@5.9.3) livekit-client: specifier: ^2.18.1 - version: 2.22.0(@types/dom-mediacapture-record@1.0.22) + version: 2.22.2(@types/dom-mediacapture-record@1.0.22) lodash-es: specifier: ^4.17.21 version: 4.18.1 @@ -4465,8 +4465,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.10: - resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} jose@6.2.9: resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} @@ -4614,8 +4614,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - livekit-client@2.22.0: - resolution: {integrity: sha512-GLtYQfRh/RsvXaOX1x609bFZ17yyKmKWDqu9JmkMKn9vIFLi2GsapRv9gT8OJO/1R2dsitrkbGmloyUfxWQsaA==} + livekit-client@2.22.2: + resolution: {integrity: sha512-o3yLYoH2eHVR/DmIZzgC96FMvtR4+g/DjeqNwS5zp12EjqrqiRULxm6Oq/YsTE97WnA6fhy23+a2hGkbce5SPA==} peerDependencies: '@types/dom-mediacapture-record': ^1 @@ -4664,6 +4664,10 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + machina@7.0.1: + resolution: {integrity: sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==} + engines: {node: '>=22.22'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -7070,21 +7074,21 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@livekit/components-core@0.12.15(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': + '@livekit/components-core@0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)': dependencies: '@floating-ui/dom': 1.7.6 - livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22) loglevel: 1.9.1 rxjs: 7.8.2 tslib: 2.8.1 - '@livekit/components-react@2.9.24(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)': + '@livekit/components-react@2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)': dependencies: - '@livekit/components-core': 0.12.15(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) + '@livekit/components-core': 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) clsx: 2.1.1 events: 3.3.0 jose: 6.2.9 - livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 @@ -7096,11 +7100,11 @@ snapshots: dependencies: '@bufbuild/protobuf': 1.10.1 - '@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))': + '@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))': dependencies: '@mediapipe/tasks-vision': 0.10.35 '@types/dom-mediacapture-transform': 0.1.11 - livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22) + livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22) '@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {} @@ -10059,7 +10063,7 @@ snapshots: jiti@2.7.0: {} - jose@6.2.10: {} + jose@6.2.12: {} jose@6.2.9: {} @@ -10203,14 +10207,15 @@ snapshots: lines-and-columns@1.2.4: {} - livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22): + livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22): dependencies: '@livekit/mutex': 1.1.1 '@livekit/protocol': 1.50.4 '@types/dom-mediacapture-record': 1.0.22 events: 3.3.0 - jose: 6.2.10 + jose: 6.2.12 loglevel: 1.9.2 + machina: 7.0.1 sdp-transform: 2.15.0 tslib: 2.8.1 typed-emitter: 2.1.0 @@ -10253,6 +10258,8 @@ snapshots: lz-string@1.5.0: {} + machina@7.0.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 From 1e9a5cfa20d7c0e75506607bc2c7f3c97c02df73 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:04:08 +0000 Subject: [PATCH 053/154] Update ghcr.io/element-hq/element-web:develop Docker digest to 6ed62b2 (#4239) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- docker-compose-playwright.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml index 54eb8104..0c7f5a89 100644 --- a/docker-compose-playwright.yml +++ b/docker-compose-playwright.yml @@ -13,7 +13,7 @@ services: - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z element-web: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:a4e9c8cb178fcabd9495da86fab32d218550747c2ef6f9baf0afca2be3436820 + image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6 element-web-1: # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. - image: ghcr.io/element-hq/element-web:develop@sha256:a4e9c8cb178fcabd9495da86fab32d218550747c2ef6f9baf0afca2be3436820 + image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6 From eddd95ba41f284250bc85776e780de94bf5b812a Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Mon, 7 Sep 2026 11:53:01 +0100 Subject: [PATCH 054/154] Log track encryption flag and cryptor events for remote tracks Extends the [RemoteTracks] logging from #4235 with the per-publication encryption flag and the ParticipantEncryptionStatusChanged / EncryptionError room events. If a publisher encrypts frames while the subscriber believes the publication is unencrypted, livekit-client bypasses the cryptor and hands raw ciphertext to the decoder, which is audible as loud noise bursts. The reverse mismatch (or a missing/invalid key) drops frames instead. Neither case is visible in a rageshake today. --- .../remoteMembers/Connection.test.ts | 26 ++++++++++++--- .../CallViewModel/remoteMembers/Connection.ts | 33 +++++++++++++++++-- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/state/CallViewModel/remoteMembers/Connection.test.ts b/src/state/CallViewModel/remoteMembers/Connection.test.ts index 9d098d44..248d66fb 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.test.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.test.ts @@ -516,11 +516,12 @@ describe("remote track logging", () => { it("logs remote participant and track events on the connection logger", () => { setupTest(); const info = vi.fn(); + const warn = vi.fn(); const testLogger = { getChild: (): unknown => testLogger, info, debug: vi.fn(), - warn: vi.fn(), + warn, error: vi.fn(), } as unknown as Logger; new Connection( @@ -545,6 +546,7 @@ describe("remote track logging", () => { source: "microphone", trackSid: "TR_mic", isMuted: false, + isEncrypted: true, } as unknown as RemoteTrackPublication; const messages = (): string[] => info.mock.calls.map((c) => c[0] as string); @@ -564,11 +566,27 @@ describe("remote track logging", () => { ); expect(messages()).toEqual([ "Participant connected: @bob:example.org:DEV111 (PA_bob)", - "Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 muted=false", - "Muted: audio microphone TR_mic of @bob:example.org:DEV111", - "Stream paused: audio microphone TR_mic of @bob:example.org:DEV111", + "Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true muted=false", + "Muted: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true", + "Stream paused: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true", ]); + // Encryption status changes are logged; cryptor errors are warnings + fakeLivekitRoom.emit( + RoomEvent.ParticipantEncryptionStatusChanged, + false, + bob, + ); + expect(messages().at(-1)).toBe( + "Encryption status of @bob:example.org:DEV111: encrypted=false", + ); + const cryptorError = new Error("missing key at index 3"); + fakeLivekitRoom.emit(RoomEvent.EncryptionError, cryptorError, bob); + expect(warn).toHaveBeenCalledWith( + "Encryption error for @bob:example.org:DEV111:", + cryptorError, + ); + // Local mute events are already logged by the Publisher fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, { ...fakeLocalParticipant, diff --git a/src/state/CallViewModel/remoteMembers/Connection.ts b/src/state/CallViewModel/remoteMembers/Connection.ts index 9310c9b8..10478cb7 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.ts @@ -186,8 +186,11 @@ export class Connection { private logRemoteTrackEvents(): void { const room = this.livekitRoom; const log = this.logger.getChild("[RemoteTracks]"); + // The encryption flag matters: if the publisher encrypts but this client + // believes the track is unencrypted, the cryptor is bypassed and raw + // ciphertext reaches the decoder (audible as loud noise bursts). const track = (pub: TrackPublication, p: Participant): string => - `${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity}`; + `${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity} encrypted=${pub.isEncrypted}`; const onParticipantConnected = (p: RemoteParticipant): void => log.info(`Participant connected: ${p.identity} (${p.sid})`); @@ -232,6 +235,20 @@ export class Connection { state: Track.StreamState, p: RemoteParticipant, ): void => log.info(`Stream ${state}: ${track(pub, p)}`); + const onEncryptionStatusChanged = ( + encrypted: boolean, + p?: Participant, + ): void => + log.info( + `Encryption status of ${p?.identity ?? "unknown participant"}: encrypted=${encrypted}`, + ); + // livekit-client throttles these per cryptor; they indicate frames being + // dropped (missing/invalid key), which is the other half of the picture. + const onEncryptionError = (error: Error, p?: Participant): void => + log.warn( + `Encryption error for ${p?.identity ?? "unknown participant"}:`, + error, + ); room .on(RoomEvent.ParticipantConnected, onParticipantConnected) @@ -243,7 +260,12 @@ export class Connection { .on(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed) .on(RoomEvent.TrackMuted, onTrackMuted) .on(RoomEvent.TrackUnmuted, onTrackUnmuted) - .on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged); + .on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged) + .on( + RoomEvent.ParticipantEncryptionStatusChanged, + onEncryptionStatusChanged, + ) + .on(RoomEvent.EncryptionError, onEncryptionError); this.scope.onEnd(() => { room .off(RoomEvent.ParticipantConnected, onParticipantConnected) @@ -255,7 +277,12 @@ export class Connection { .off(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed) .off(RoomEvent.TrackMuted, onTrackMuted) .off(RoomEvent.TrackUnmuted, onTrackUnmuted) - .off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged); + .off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged) + .off( + RoomEvent.ParticipantEncryptionStatusChanged, + onEncryptionStatusChanged, + ) + .off(RoomEvent.EncryptionError, onEncryptionError); }); } From 6a9583a2ce2738c6b1aa95e29e47e35662c97685 Mon Sep 17 00:00:00 2001 From: Robin Date: Mon, 7 Sep 2026 12:53:47 +0200 Subject: [PATCH 055/154] Adapt delayed leave timings when delegation is available Splits the config options for the timings of a delayed leave event into two sets: one for when delegation is available (as you can relax the timings and get more stable calls this way), and another for when it's unavailable (as we must continue to gracefully downgrade even after Matrix 2.0 is fully rolled out). This works by bluntly hitting the delegation endpoints without auth before joining to check for a 404. --- config/config.devenv.json | 2 - config/config.sample.json | 2 - config/config_netlify_preview.json | 2 - config/config_netlify_preview_sdk.json | 2 - src/config/ConfigOptions.ts | 88 +++--- src/livekit/openIDSFU.test.ts | 8 +- src/livekit/openIDSFU.ts | 10 +- src/state/CallViewModel/CallViewModel.ts | 29 +- .../localMember/LocalMember.test.ts | 270 +++++++++++------- .../CallViewModel/localMember/LocalMember.ts | 123 +++++--- src/utils/observable.test.ts | 23 +- src/utils/observable.ts | 10 +- vite-embedded.config.ts | 2 - 13 files changed, 363 insertions(+), 208 deletions(-) diff --git a/config/config.devenv.json b/config/config.devenv.json index 48602406..294209a6 100644 --- a/config/config.devenv.json +++ b/config/config.devenv.json @@ -9,8 +9,6 @@ "matrix_rtc_session": { "wait_for_key_rotation_ms": 3000, "membership_event_expiry_ms": 180000000, - "delayed_leave_event_delay_ms": 18000, - "delayed_leave_event_restart_ms": 4000, "network_error_retry_ms": 100 } } diff --git a/config/config.sample.json b/config/config.sample.json index 0baa522e..9f7ab61a 100644 --- a/config/config.sample.json +++ b/config/config.sample.json @@ -13,8 +13,6 @@ "matrix_rtc_session": { "wait_for_key_rotation_ms": 3000, "membership_event_expiry_ms": 180000000, - "delayed_leave_event_delay_ms": 18000, - "delayed_leave_event_restart_ms": 4000, "network_error_retry_ms": 100 } } diff --git a/config/config_netlify_preview.json b/config/config_netlify_preview.json index 313f0d02..179177ba 100644 --- a/config/config_netlify_preview.json +++ b/config/config_netlify_preview.json @@ -9,8 +9,6 @@ "matrix_rtc_session": { "wait_for_key_rotation_ms": 3000, "membership_event_expiry_ms": 180000000, - "delayed_leave_event_delay_ms": 18000, - "delayed_leave_event_restart_ms": 4000, "network_error_retry_ms": 100 }, "posthog": { diff --git a/config/config_netlify_preview_sdk.json b/config/config_netlify_preview_sdk.json index 784f0c7e..fa60fe8d 100644 --- a/config/config_netlify_preview_sdk.json +++ b/config/config_netlify_preview_sdk.json @@ -9,8 +9,6 @@ "matrix_rtc_session": { "wait_for_key_rotation_ms": 3000, "membership_event_expiry_ms": 180000000, - "delayed_leave_event_delay_ms": 18000, - "delayed_leave_event_restart_ms": 4000, "network_error_retry_ms": 100 } } diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index 536966e0..872e3516 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -24,6 +24,35 @@ export enum MatrixRTCMode { Matrix_2_0 = "matrix_2_0", } +export interface DelayedLeaveTimings { + /** + * The delay (in milliseconds) with which delayed leave events are sent. + * + * If the server receives no keep-alives from the client for any longer than + * this duration, it will send the leave event, automatically removing the + * user from the call. + */ + delay_ms?: number; + + /** + * How frequently (in milliseconds) the client sends keep-alives to the server + * to restart the timer for a delayed leave event. Should be less than + * {@link DelayedLeaveTimings.delay_ms}. + */ + restart_ms?: number; + + /** + * The time (in milliseconds) after which we consider a delayed event restart HTTP request to have failed. + * Setting this to a lower value will result in more frequent retries, but then we will also give up earlier. + * + * In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs + * helps by keeping more delayed event reset candidates in flight, + * improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration, + * but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.) + */ + restart_timeout_ms?: number; +} + export interface ConfigOptions { /** * The Posthog endpoint to which analytics data will be sent. @@ -184,29 +213,6 @@ export interface ConfigOptions { */ wait_for_key_rotation_ms?: number; - /** - * The duration (in milliseconds) after the most recent keep-alive (delayed leave event restart) - * that the server waits before sending the leave MatrixRTC membership event. - */ - delayed_leave_event_delay_ms?: number; - - /** - * The time (in milliseconds) after which we consider a delayed event restart http request to have failed. - * Setting this to a lower value will result in more frequent retries but also a higher chance of failiour. - * - * In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs - * helps by keeping more delayed event reset candidates in flight, - * improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration, - * but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.) - */ - delayed_leave_event_restart_local_timeout_ms?: number; - - /** - * The time interval (in milliseconds) at which the client sends membership keep-alive - * messages to the server by restarting the timer for the delayed leave event. - */ - delayed_leave_event_restart_ms?: number; - /** * How long we wait before retrying after a network error on any of the requests. */ @@ -231,9 +237,28 @@ export interface ConfigOptions { * Defaults to the js-sdk default (undefined). Which means that rotation will always happen. */ key_rotation_participant_limit?: number; + + /** + * Timing options for delayed leave events, which are used to remove a user + * from a call when they lose connection. + */ + delayed_leave?: DelayedLeaveTimings; + + /** + * Timing options for delayed leave events, in cases where the ability to + * send the event can be delegated to the SFU. + * + * We recommend setting {@link DelayedLeaveTimings.delay_ms} >> + * {@link sync_disconnect_grace_period_ms} here. + */ + delegated_delayed_leave?: DelayedLeaveTimings; }; } +export interface ResolvedDelayedLeaveTimings extends DelayedLeaveTimings { + delay_ms: number; // Required +} + // Overrides members from ConfigOptions that are always provided by the // default config and are therefore non-optional. export interface ResolvedConfigOptions extends ConfigOptions { @@ -257,19 +282,15 @@ export interface ResolvedConfigOptions extends ConfigOptions { > >; }; - matrix_rtc_session: { - wait_for_key_rotation_ms?: number; - delayed_leave_event_delay_ms: number; - delayed_leave_event_restart_local_timeout_ms?: number; - delayed_leave_event_restart_ms?: number; + matrix_rtc_session: ConfigOptions["matrix_rtc_session"] & { network_error_retry_ms: number; - membership_event_expiry_ms?: number; - key_rotation_participant_limit?: number; + delayed_leave: ResolvedDelayedLeaveTimings; + delegated_delayed_leave: ResolvedDelayedLeaveTimings; }; } export const DEFAULT_CONFIG: ResolvedConfigOptions = { - sync_disconnect_grace_period_ms: 10000, + sync_disconnect_grace_period_ms: 10_000, ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf", media_quality: { video_codec: "vp8", @@ -285,7 +306,8 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = { }, }, matrix_rtc_session: { - delayed_leave_event_delay_ms: 10000, - network_error_retry_ms: 1000, + network_error_retry_ms: 1_000, + delayed_leave: { delay_ms: 18_000, restart_ms: 4_000 }, + delegated_delayed_leave: { delay_ms: 3_600_000, restart_ms: 300_000 }, }, }; diff --git a/src/livekit/openIDSFU.test.ts b/src/livekit/openIDSFU.test.ts index 2ddb6c95..f759a3aa 100644 --- a/src/livekit/openIDSFU.test.ts +++ b/src/livekit/openIDSFU.test.ts @@ -148,7 +148,7 @@ describe("getSFUConfigWithOpenID", () => { // Verify, that the request contains the expected delay parameters if ( body.delay_id === "mock_delay_id" && - body.delay_timeout === 10000 && + body.delay_timeout === 3600000 && body.delay_cs_api_url === "https://homeserverserver.org/cs_api" ) { return { @@ -229,7 +229,7 @@ describe("getSFUConfigWithOpenID", () => { expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token"); expect(calls[0][1]).toStrictEqual({ // check if it uses correct delayID! - body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":10000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}', + body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}', method: "POST", headers: { "Content-Type": "application/json", @@ -239,7 +239,7 @@ describe("getSFUConfigWithOpenID", () => { expect(calls[1][0]).toStrictEqual("https://sfu.example.org/sfu/get"); expect(calls[1][1]).toStrictEqual({ - body: '{"room":"!example_room_id","device_id":"DEVICE","delay_id":"mock_delay_id","delay_timeout":10000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}', + body: '{"room":"!example_room_id","device_id":"DEVICE","delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}', headers: { "Content-Type": "application/json", }, @@ -284,7 +284,7 @@ describe("getSFUConfigWithOpenID", () => { expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token"); expect(calls[0][1]).toStrictEqual({ // check if it uses correct delayID! - body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":10000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}', + body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}', method: "POST", headers: { "Content-Type": "application/json", diff --git a/src/livekit/openIDSFU.ts b/src/livekit/openIDSFU.ts index 00cf69b1..67dd5e9e 100644 --- a/src/livekit/openIDSFU.ts +++ b/src/livekit/openIDSFU.ts @@ -204,11 +204,10 @@ async function getLiveKitJWT( let bodyDalayParts: IDelayParams = {}; // Also check for empty string if (delayId && delayEndpointBaseUrl) { - const delayTimeoutMs = - Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms; bodyDalayParts = { delay_id: delayId, - delay_timeout: delayTimeoutMs, + delay_timeout: + Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms, delay_cs_api_url: delayEndpointBaseUrl, }; } @@ -288,11 +287,10 @@ export async function getLiveKitJWTWithDelayDelegation( let bodyDalayParts = {}; // Also check for empty string if (delayId && delayEndpointBaseUrl) { - const delayTimeoutMs = - Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms; bodyDalayParts = { delay_id: delayId, - delay_timeout: delayTimeoutMs, + delay_timeout: + Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms, delay_cs_api_url: delayEndpointBaseUrl, }; } diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index c854363b..62f05a47 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -64,7 +64,10 @@ import { showReactions, } from "../../settings/settings"; import { Config } from "../../config/Config"; -import { MatrixRTCMode } from "../../config/ConfigOptions"; +import { + MatrixRTCMode, + type ResolvedDelayedLeaveTimings, +} from "../../config/ConfigOptions"; import { isFirefox, platform } from "../../Platform"; import { setPipEnabled$ } from "../../controls"; import { TileStore } from "../TileStore"; @@ -562,16 +565,6 @@ export function createCallViewModel$( localUser: { userId, deviceId }, }); - const connectOptions$ = scope.behavior( - matrixRTCMode$.pipe( - map((mode) => ({ - encryptMedia: livekitKeyProvider !== undefined, - // TODO. This might need to get called again on each change of matrixRTCMode... - matrixRTCMode: mode, - })), - ), - ); - const localMembership = createLocalMembership$({ scope, homeserverConnected: createHomeserverConnected$( @@ -580,12 +573,21 @@ export function createCallViewModel$( matrixRTCSession, ), muteStates, - joinMatrixRTC: (transport: LivekitTransportConfig) => { + joinMatrixRTC: ( + transport: LivekitTransportConfig, + delayedLeaveTimings: ResolvedDelayedLeaveTimings, + ) => { return enterRTCSession( matrixRTCSession, ownMembershipIdentity, transport, - connectOptions$.value, + { + encryptMedia: livekitKeyProvider !== undefined, + // We merely sample the current mode here, so the user would need to + // manually rejoin to switch to a different one + matrixRTCMode: matrixRTCMode$.value, + delayedLeaveTimings, + }, ); }, createPublisherFactory: (connection: Connection) => { @@ -603,6 +605,7 @@ export function createCallViewModel$( matrixRTCSession, localTransport$, roomId: matrixRoom.roomId, + baseUrl: client.baseUrl, logger: logger.getChild(`[${Date.now()}]`), }); diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index aec64177..66cd8cda 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -19,13 +19,18 @@ import { beforeAll, afterAll, beforeEach, + afterEach, } from "vitest"; import { BehaviorSubject, map, of } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; import { type LocalParticipant, type LocalTrack } from "livekit-client"; +import fetchMock from "fetch-mock"; import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics"; -import { MatrixRTCMode } from "../../../config/ConfigOptions"; +import { + MatrixRTCMode, + type ResolvedDelayedLeaveTimings, +} from "../../../config/ConfigOptions"; import { type HomeserverDisconnectReason } from "./HomeserverConnected"; import { flushPromises, @@ -35,6 +40,7 @@ import { mockMuteStates, withTestScheduler, ownMemberMock, + testScope, } from "../../../utils/test"; import { TransportState, @@ -95,112 +101,109 @@ describe("watchScreenShareToggle", () => { }); }); -describe("LocalMembership", () => { - describe("enterRTCSession", () => { - it("It joins the correct Session", () => { - mockConfig({ - livekit: { livekit_service_url: "http://my-default-service-url.com" }, - }); +const timings: ResolvedDelayedLeaveTimings = { + delay_ms: 10000, + restart_ms: 4000, + restart_timeout_ms: 1000, +}; - const mockedSession = vi.mocked({ - room: { - roomId: "roomId", - client: { - getDomain: vi.fn().mockReturnValue("example.org"), - getOpenIdToken: vi.fn().mockResolvedValue({ - access_token: "ACCCESS_TOKEN", - token_type: "Bearer", - matrix_server_name: "localhost", - expires_in: 10000, - }), - }, - }, - memberships: [], - joinRTCSession: vi.fn(), - }) as unknown as MatrixRTCSession; +const delegatedTimings: ResolvedDelayedLeaveTimings = { + delay_ms: timings.delay_ms * 10, + restart_ms: timings.restart_ms! * 10, + restart_timeout_ms: timings.restart_timeout_ms! * 10, +}; - enterRTCSession( - mockedSession, - ownMemberMock, - { - livekit_alias: "roomId", - livekit_service_url: "http://my-livekit-service-url.com", - type: "livekit", - }, - { - encryptMedia: true, - matrixRTCMode: MATRIX_RTC_MODE, - }, - ); +describe("enterRTCSession", () => { + const transport: LivekitTransportConfig = { + livekit_alias: "roomId", + livekit_service_url: "http://my-livekit-service-url.com", + type: "livekit", + }; - expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith( - { - deviceId: "DEVICE", - memberId: "@alice:example.org:DEVICE", - userId: "@alice:example.org", - }, - [], - { - livekit_alias: "roomId", - livekit_service_url: "http://my-livekit-service-url.com", - type: "livekit", - }, - expect.objectContaining({ manageMediaKeys: true }), - ); - }); + const options = { + encryptMedia: true, + matrixRTCMode: MATRIX_RTC_MODE, + delayedLeaveTimings: timings, + }; - it("passes keyRotationParticipantLimit from config to joinRTCSession", () => { - mockConfig({ - livekit: { livekit_service_url: "http://my-default-service-url.com" }, - matrix_rtc_session: { - delayed_leave_event_delay_ms: 0, - network_error_retry_ms: 0, - key_rotation_participant_limit: 50, - }, - }); - - const mockedSession = vi.mocked({ - room: { - roomId: "roomId", - client: { - getDomain: vi.fn().mockReturnValue("example.org"), - getOpenIdToken: vi.fn().mockResolvedValue({ - access_token: "ACCCESS_TOKEN", - token_type: "Bearer", - matrix_server_name: "localhost", - expires_in: 10000, - }), - }, - }, - memberships: [], - joinRTCSession: vi.fn(), - }) as unknown as MatrixRTCSession; - - enterRTCSession( - mockedSession, - ownMemberMock, - { - livekit_alias: "roomId", - livekit_service_url: "http://my-livekit-service-url.com", - type: "livekit", - }, - { - encryptMedia: true, - matrixRTCMode: MATRIX_RTC_MODE, - }, - ); - - expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith( - expect.any(Object), - [], - expect.any(Object), - expect.objectContaining({ - keyRotationParticipantLimit: 50, + const mockedSession = vi.mocked({ + room: { + roomId: "roomId", + client: { + getDomain: vi.fn().mockReturnValue("example.org"), + getOpenIdToken: vi.fn().mockResolvedValue({ + access_token: "ACCCESS_TOKEN", + token_type: "Bearer", + matrix_server_name: "localhost", + expires_in: 10000, }), - ); - }); + }, + }, + memberships: [], + joinRTCSession: vi.fn(), + }) as unknown as MatrixRTCSession; + + beforeEach(() => + mockConfig({ + livekit: { livekit_service_url: "http://my-default-service-url.com" }, + }), + ); + + it("It joins the correct Session", () => { + enterRTCSession(mockedSession, ownMemberMock, transport, options); + + expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith( + { + deviceId: "DEVICE", + memberId: "@alice:example.org:DEVICE", + userId: "@alice:example.org", + }, + [], + transport, + expect.objectContaining({ manageMediaKeys: true }), + ); }); + it("passes keyRotationParticipantLimit from config to joinRTCSession", () => { + mockConfig({ + livekit: { livekit_service_url: "http://my-default-service-url.com" }, + matrix_rtc_session: { + network_error_retry_ms: 0, + key_rotation_participant_limit: 50, + delayed_leave: timings, + delegated_delayed_leave: timings, + }, + }); + + enterRTCSession(mockedSession, ownMemberMock, transport, options); + + expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith( + expect.any(Object), + [], + expect.any(Object), + expect.objectContaining({ + keyRotationParticipantLimit: 50, + }), + ); + }); + + it("uses the specified delayed leave timings", () => { + enterRTCSession(mockedSession, ownMemberMock, transport, options); + + expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ + delayedLeaveEventRestartMs: timings.restart_ms, + delayedLeaveEventDelayMs: timings.delay_ms, + delayedLeaveEventRestartLocalTimeoutMs: timings.restart_timeout_ms, + }), + ); + }); +}); + +describe("LocalMembership", () => { const defaultCreateLocalMemberValues = { options: constant({ encryptMedia: false, @@ -226,8 +229,26 @@ describe("LocalMembership", () => { rtsSession$: constant(RTCMemberStatus.Connected), }, roomId: "!test-room-id:example.org", + baseUrl: "https://matrix.example.org", }; + beforeEach(() => { + mockConfig({ + livekit: { livekit_service_url: "http://my-default-service-url.com" }, + matrix_rtc_session: { + network_error_retry_ms: 1000, + delayed_leave: timings, + delegated_delayed_leave: delegatedTimings, + }, + }); + fetchMock.catch(404); + }); + + afterEach(async () => { + void (await fetchMock.flush()); + fetchMock.reset(); + }); + it("throws error on missing RTC config error", () => { withTestScheduler(({ scope, hot, behavior, expectObservable }) => { const localTransport$ = scope.behavior( @@ -256,7 +277,6 @@ describe("LocalMembership", () => { connectionManager: mockConnectionManager, localTransport$: behavior("a", { a: aLocalTransport }), }); - localMembership.requestJoinAndPublish(); expectObservable(localMembership.localMemberState$).toBe("ne", { n: TransportState.Waiting, @@ -299,9 +319,8 @@ describe("LocalMembership", () => { scope, ...defaultCreateLocalMemberValues, connectionManager: mockConnectionManager, - localTransport$: behavior("a", { a: aLocalTransport }), + localTransport$: constant(aLocalTransport), }); - localMembership.requestJoinAndPublish(); expectObservable(localMembership.localMemberState$).toBe("n-e", { n: TransportState.Waiting, @@ -397,6 +416,51 @@ describe("LocalMembership", () => { livekitRoom: mockLivekitRoom({}), } as unknown as Connection; + it.each([ + ["no", null, timings], + [ + "homeserver", + "https://matrix.example.org/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave", + delegatedTimings, + ], + ["transport", "/a/delegate_delayed_leave", delegatedTimings], + ])( + "joins session with %s delegation support", + async (_serviceName, delegationUrl, delayedLeaveTimings) => { + const scope = testScope(); + + const activeTransport$ = constant(aTransportWithSFUConfig); + const aLocalTransport: LocalTransport = { + advertised$: constant(aTransport), + active$: activeTransport$, + }; + const connectionManagerData = new ConnectionManagerData(); + const joinMatrixRTC = vi.fn(); + + if (delegationUrl !== null) + fetchMock.post(delegationUrl, () => ({ status: 401, body: {} })); + + const localMembership = createLocalMembership$({ + scope, + ...defaultCreateLocalMemberValues, + connectionManager: { + connectionManagerData$: constant(new Epoch(connectionManagerData)), + }, + localTransport$: constant(aLocalTransport), + joinMatrixRTC, + }); + + localMembership.requestJoinAndPublish(); + void (await fetchMock.flush()); + await flushPromises(); + // Joins with timings appropriate for the level of delegation support + expect(joinMatrixRTC).toHaveBeenCalledWith( + aTransport, + delayedLeaveTimings, + ); + }, + ); + it("recreates publisher if new connection is used, always unpublish and end tracks", async () => { const scope = new ObservableScope(); diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index dfe8936a..838b14ee 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -62,7 +62,10 @@ import { screenShareCodec, parseResolution, } from "../../../settings/settings.ts"; -import { MatrixRTCMode } from "../../../config/ConfigOptions.ts"; +import { + MatrixRTCMode, + type ResolvedDelayedLeaveTimings, +} from "../../../config/ConfigOptions.ts"; import { Config } from "../../../config/Config.ts"; import { ConnectionState, @@ -72,6 +75,8 @@ import { import { type HomeserverConnected } from "./HomeserverConnected.ts"; import { type LocalTransport } from "./LocalTransport.ts"; import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts"; +import { doNetworkOperationWithRetry } from "../../../utils/matrix.ts"; +import { or$ } from "../../../utils/observable.ts"; export enum TransportState { /** Not even a transport is available to the LocalMembership */ @@ -133,7 +138,10 @@ interface Props { muteStates: MuteStates; connectionManager: IConnectionManager; createPublisherFactory: (connection: Connection) => Publisher; - joinMatrixRTC: (transport: LivekitTransportConfig) => void; + joinMatrixRTC: ( + transport: LivekitTransportConfig, + delayedLeaveTimings: ResolvedDelayedLeaveTimings, + ) => void; homeserverConnected: HomeserverConnected; roomId: string; localTransport$: Behavior; @@ -141,6 +149,7 @@ interface Props { MatrixRTCSession, "updateCallIntent" | "leaveRoomSession" >; + baseUrl: string; logger: Logger; } @@ -159,6 +168,7 @@ interface Props { * @param props.logger The logger to use. * @param props.muteStates The mute states for video and audio. * @param props.matrixRTCSession The matrix RTC session to join. + * @param props.baseUrl Base URL of the homeserver. * @param props.roomId The room ID used as the call identifier in analytics events. * @returns * - publisher: The handle to create tracks and publish them to the room. @@ -177,6 +187,7 @@ export const createLocalMembership$ = ({ logger: parentLogger, muteStates, matrixRTCSession, + baseUrl, roomId, }: Props): { /** @@ -236,11 +247,58 @@ export const createLocalMembership$ = ({ return of(null); }; - // This is the transport that we will advertise in our membership. - const advertisedTransport$ = localTransport$.pipe( + async function checkDelegationSupport( + endpointUrl: string, + serviceName: string, + ): Promise { + logger.info(`Checking whether ${serviceName} supports delegation…`); + try { + // Bluntly hit the endpoint without auth to check for a 404 + const res = await doNetworkOperationWithRetry(async () => + fetch(endpointUrl, { method: "POST" }), + ); + if (res.status === 404) { + logger.warn(`${serviceName} does not support delegation`); + return false; + } else { + logger.info(`${serviceName} supports delegation`); + return true; + } + } catch (e) { + logger.warn( + `Failed to determine whether ${serviceName} supports delegation, assuming no support`, + e, + ); + return false; + } + } + + const homeserverSupportsDelegation = checkDelegationSupport( + baseUrl + + "/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave", + "homeserver", + ); + + // The transport that we will advertise in our membership, paired with info as + // to whether delayed event delegation is supported + const joinParams$ = localTransport$.pipe( switchMap((lt) => lt.advertised$), catchError(handleTransportError), distinctUntilChanged(areLivekitTransportsEqual), + switchMap((transport) => { + if (transport === null) return of(null); + const transportSupportsDelegation = checkDelegationSupport( + transport.livekit_service_url + "/delegate_delayed_leave", + `transport ${transport.livekit_service_url}`, + ); + return or$( + from(homeserverSupportsDelegation), + from(transportSupportsDelegation), + ).pipe( + map((delegationSupported) => ({ transport, delegationSupported })), + startWith(null), + ); + }), ); // Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error. @@ -617,16 +675,20 @@ export const createLocalMembership$ = ({ // Keep matrix rtc session in sync with advertisedTransport$, connectRequested$ scope.reconcile( - scope.behavior( - combineLatest([advertisedTransport$, joinAndPublishRequested$]), - ), - async ([transport, shouldConnect]) => { - if (!transport) return; + scope.behavior(combineLatest([joinParams$, joinAndPublishRequested$])), + async ([joinParams, shouldConnect]) => { + if (!joinParams) return; // if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration. if (!shouldConnect) return; + const sessionConfig = Config.get().matrix_rtc_session; try { - joinMatrixRTC(transport); + joinMatrixRTC( + joinParams.transport, + joinParams.delegationSupported + ? sessionConfig.delegated_delayed_leave + : sessionConfig.delayed_leave, + ); } catch (error) { logger.error("Error entering RTC session", error); if (error instanceof Error) @@ -864,6 +926,7 @@ export function observeSharingScreen$(p: Participant): Observable { interface EnterRTCSessionOptions { encryptMedia: boolean; matrixRTCMode: MatrixRTCMode; + delayedLeaveTimings: ResolvedDelayedLeaveTimings; } /** @@ -876,6 +939,7 @@ interface EnterRTCSessionOptions { * @param rtcSession - The MatrixRTCSession to join. * @param ownMembershipIdentity - Options for entering the RTC session. * @param transport - The LivekitTransport to use for this session. + * @param delayedLeaveTimings - The preferred timings for delayed leave events. * @param options - `encryptMedia`: Whether to encrypt media `matrixRTCMode`: The Matrix RTC mode to use. * @throws If the widget could not send ElementWidgetActions.JoinCall action. */ @@ -884,9 +948,8 @@ export function enterRTCSession( rtcSession: MatrixRTCSession, ownMembershipIdentity: CallMembershipIdentityParts, transport: LivekitTransportConfig, - options: EnterRTCSessionOptions, + { encryptMedia, matrixRTCMode, delayedLeaveTimings }: EnterRTCSessionOptions, ): void { - const { encryptMedia, matrixRTCMode } = options; PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId); @@ -894,7 +957,11 @@ export function enterRTCSession( // have started tracking by the time calls start getting created. // groupCallOTelMembership?.onJoinCall(); - const { matrix_rtc_session: matrixRtcSessionConfig } = Config.get(); + const { + sync_disconnect_grace_period_ms: gracePeriod, + matrix_rtc_session: sessionConfig, + } = Config.get(); + const retryInterval = sessionConfig.network_error_retry_ms; const { sendNotificationType: notificationType, callIntent } = getUrlParams(); const multiSFU = matrixRTCMode === MatrixRTCMode.Compatibility || @@ -912,16 +979,11 @@ export function enterRTCSession( }; } - // Calculates `maximumNetworkErrorRetryCount`. The connection is failed if EITHER: - // - The /sync loop is unresponsive for > `gracePeriod` ms, or - // - A delayed leave event is emitted (after `leaveDelay` ms period). - // Note: Use leaveDelay >> gracePeriod for delegated leave events. - const gracePeriod = Config.get().sync_disconnect_grace_period_ms; - const leaveDelay = matrixRtcSessionConfig?.delayed_leave_event_delay_ms; - const retryInterval = matrixRtcSessionConfig?.network_error_retry_ms; - + // Set maximumNetworkErrorRetryCount such that we will consider the client + // disconnected as soon as either it fails to sync for longer than the grace + // period, or it is likely that a delayed leave event has been sent. // Math.min is used to account for the respective worst case: /sync not available or leave event emitted. - const maxWaitTime = Math.min(gracePeriod, leaveDelay); + const maxWaitTime = Math.min(gracePeriod, delayedLeaveTimings.delay_ms); const maximumNetworkErrorRetryCount = Math.ceil(maxWaitTime / retryInterval) + 1; @@ -936,18 +998,15 @@ export function enterRTCSession( notificationType, callIntent, manageMediaKeys: encryptMedia, - delayedLeaveEventRestartMs: - matrixRtcSessionConfig?.delayed_leave_event_restart_ms, - delayedLeaveEventDelayMs: - matrixRtcSessionConfig?.delayed_leave_event_delay_ms, + delayedLeaveEventRestartMs: delayedLeaveTimings.restart_ms, + delayedLeaveEventDelayMs: delayedLeaveTimings.delay_ms, delayedLeaveEventRestartLocalTimeoutMs: - matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms, - networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms, - makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms, - membershipEventExpiryMs: - matrixRtcSessionConfig?.membership_event_expiry_ms, + delayedLeaveTimings.restart_timeout_ms, + networkErrorRetryMs: sessionConfig?.network_error_retry_ms, + makeKeyDelay: sessionConfig?.wait_for_key_rotation_ms, + membershipEventExpiryMs: sessionConfig?.membership_event_expiry_ms, keyRotationParticipantLimit: - matrixRtcSessionConfig?.key_rotation_participant_limit, + sessionConfig?.key_rotation_participant_limit, unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0, maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount, }, diff --git a/src/utils/observable.test.ts b/src/utils/observable.test.ts index 80cbb3c8..392f742a 100644 --- a/src/utils/observable.test.ts +++ b/src/utils/observable.test.ts @@ -9,9 +9,30 @@ import { expect, test } from "vitest"; import { type Observable, of, Subject, switchMap } from "rxjs"; import { withTestScheduler } from "./test"; -import { filterBehavior, generateItems, pauseWhen } from "./observable"; +import { or$, filterBehavior, generateItems, pauseWhen } from "./observable"; import { type Behavior } from "../state/Behavior"; +const yesNo = { + y: true, + n: false, +}; + +test("or$", () => { + withTestScheduler(({ behavior, expectObservable }) => { + const input1Marbles = "ny--n--"; + const input2Marbles = "n-y--n-"; + const input3Marbles = "n--y--n"; + const outputMarbles = "nyyyyyn"; + expectObservable( + or$( + behavior(input1Marbles, yesNo), + behavior(input2Marbles, yesNo), + behavior(input3Marbles, yesNo), + ), + ).toBe(outputMarbles, yesNo); + }); +}); + test("pauseWhen", () => { withTestScheduler(({ behavior, expectObservable }) => { const inputMarbles = " abcdefgh-i-jk-"; diff --git a/src/utils/observable.ts b/src/utils/observable.ts index c32254db..d4ea90b2 100644 --- a/src/utils/observable.ts +++ b/src/utils/observable.ts @@ -114,13 +114,11 @@ export function getValue(state$: Observable): T { } /** - * Creates an Observable that has a value of true whenever all its inputs are - * true. - * - * @public + * Creates an Observable that has a value of true whenever some of its inputs + * are true. */ -export function and$(...inputs: Observable[]): Observable { - return combineLatest(inputs, (...flags) => flags.every((flag) => flag)); +export function or$(...inputs: Observable[]): Observable { + return combineLatest(inputs, (...flags) => flags.some((flag) => flag)); } /** diff --git a/vite-embedded.config.ts b/vite-embedded.config.ts index 27a42fbb..228d2cf8 100644 --- a/vite-embedded.config.ts +++ b/vite-embedded.config.ts @@ -27,8 +27,6 @@ export default defineConfig((env) => data: { matrix_rtc_session: { wait_for_key_rotation_ms: 5000, - delayed_leave_event_restart_ms: 4000, - delayed_leave_event_delay_ms: 18000, }, }, }, From 65e1b845a79aa9fd16e767db68d4109bd8dcae4e Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 7 Sep 2026 13:18:00 +0200 Subject: [PATCH 056/154] Apply suggestion from @Johennes --- src/state/CallViewModel/remoteMembers/Connection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state/CallViewModel/remoteMembers/Connection.ts b/src/state/CallViewModel/remoteMembers/Connection.ts index 10478cb7..faacc28b 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.ts @@ -243,7 +243,7 @@ export class Connection { `Encryption status of ${p?.identity ?? "unknown participant"}: encrypted=${encrypted}`, ); // livekit-client throttles these per cryptor; they indicate frames being - // dropped (missing/invalid key), which is the other half of the picture. + // dropped (missing/invalid key). const onEncryptionError = (error: Error, p?: Participant): void => log.warn( `Encryption error for ${p?.identity ?? "unknown participant"}:`, From 365204d04ca99169ca21c8bb393024196292db76 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Mon, 7 Sep 2026 14:50:36 +0200 Subject: [PATCH 057/154] fix media queries in component based element call. --- README.md | 3 + playwright/component/component-call.spec.ts | 82 ++++++++++++++++++++- src/AppBar.module.css | 4 +- src/Header.module.css | 2 +- src/base.css | 12 ++- src/button/ReactionToggleButton.module.css | 2 +- src/components/CallFooter.module.css | 16 ++-- src/grid/OneOnOneMobileLayout.module.css | 2 +- src/grid/SpotlightExpandedLayout.module.css | 2 +- src/room/CallEndedView.module.css | 2 +- src/room/InCallView.module.css | 2 +- src/room/LobbyView.module.css | 4 +- src/room/VideoPreview.module.css | 2 +- 13 files changed, 114 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 0732ff41..273846af 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,9 @@ logged along the bottom. The call lays itself out for the size of the element it is mounted in, not the window: a host that shrinks the container to a corner of its page gets the picture-in-picture layout, just as a host that shrank the whole iframe used to. +The breakpoints in Element Call's stylesheets are `@container element-call` +queries against its root element for the same reason; for the standalone app +the root is the page, so they mean what the media queries they replaced did. The component's stylesheet is confined to the element it is mounted in: the build rewrites every selector so that it matches only Element Call's root or diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index 97a00267..784158ad 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { expect, type Locator, test } from "@playwright/test"; +import { expect, type Locator, type Page, test } from "@playwright/test"; import { createUserAndRoom, expectWithin, startHarness } from "./harness.ts"; +import { SpaHelpers } from "../spa-helpers.ts"; /** * Element Call embedded as a React component, driven through the development @@ -165,3 +166,82 @@ test("lays itself out for the space it is given, not the page", async ({ await resize(900, 700); await expect(call).not.toHaveAttribute("data-layout", "pip"); }); + +/** + * The shape of a call at whatever size it has been given: the layout it chose, + * how much of the height the tile and the footer take, and which controls the + * footer shows. Two calls with the same shape look the same, participants aside. + */ +async function callShape(scope: Page | Locator): Promise<{ + layout: string | null; + tileHeight: number; + footerHeight: number; + buttons: (string | null)[]; +}> { + const call = scope.locator("[data-layout]"); + const footer = scope.getByTestId("footer-container"); + await expect(footer).toBeVisible(); + const tile = scope.getByTestId("videoTile").first(); + await expect(tile).toBeVisible(); + const tileBox = (await tile.boundingBox())!; + const footerBox = (await footer.boundingBox())!; + const buttons = await footer + .getByRole("button") + .filter({ visible: true }) + .evaluateAll((elements) => + elements.map((element) => element.getAttribute("aria-label")), + ); + return { + layout: await call.getAttribute("data-layout"), + tileHeight: Math.round(tileBox.height), + footerHeight: Math.round(footerBox.height), + buttons, + }; +} + +test("looks the same in a small container as in a small window", async ({ + page, + browser, +}) => { + // Two calls to set up, one of them through the harness's two logins + test.setTimeout(240_000); + const size = { width: 300, height: 300 }; + + // The reference is Element Call owning a window of that size, which is what + // a mobile app's webview or a browser's picture-in-picture gives it, and + // what its small-window styling was written for. + const referenceContext = await browser.newContext({ + viewport: size, + ignoreHTTPSErrors: true, + permissions: ["microphone", "camera"], + }); + const referencePage = await referenceContext.newPage(); + await referencePage.goto("/"); + await SpaHelpers.createCall(referencePage, "Reference", "smallwindow", true); + const reference = await callShape(referencePage); + await referencePage.screenshot({ + path: test.info().outputPath("small-window.png"), + }); + + // The component gets a container of that size, in a window that is far larger + const { username, roomId } = await createUserAndRoom("smallcontainer"); + const panes = await startHarness(page, username, roomId); + const pane = panes.first(); + const container = pane.getByTestId("call-container"); + await container.evaluate((element, { width, height }) => { + element.style.width = `${width}px`; + element.style.height = `${height}px`; + }, size); + await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); + await expect(pane.locator("[data-layout]")).toBeVisible({ timeout: 60_000 }); + const component = await callShape(pane); + await container.screenshot({ + path: test.info().outputPath("small-container.png"), + }); + await referenceContext.close(); + + // The breakpoints in Element Call's stylesheets are container queries, so a + // small container gets the compact footer a small window does, rather than + // the full-width one the window's own size would call for + expect(component).toEqual(reference); +}); diff --git a/src/AppBar.module.css b/src/AppBar.module.css index faf2b0bf..1b234a22 100644 --- a/src/AppBar.module.css +++ b/src/AppBar.module.css @@ -68,7 +68,7 @@ } /* Hide everything but the subtitle in small windows */ -@media (max-height: 450px) { +@container element-call (max-height: 450px) { .bar { display: none; } @@ -166,7 +166,7 @@ } /* Hide everything but the subtitle in small windows */ - @media (max-height: 450px) { + @container element-call (max-height: 450px) { .bar:has(.subtitle) > header { grid-template-rows: var(--cpd-space-4x) minmax(var(--cpd-space-5x), auto); grid-template-areas: "." "subtitle"; diff --git a/src/Header.module.css b/src/Header.module.css index f82f5fbd..07a3e724 100644 --- a/src/Header.module.css +++ b/src/Header.module.css @@ -107,7 +107,7 @@ Please see LICENSE in the repository root for full details. gap: var(--cpd-space-1-5x); } -@media (min-width: 800px) { +@container element-call (min-width: 800px) { .headerLogo, .leftNav.hideMobile, .rightNav.hideMobile { diff --git a/src/base.css b/src/base.css index ae09f492..d31622fb 100644 --- a/src/base.css +++ b/src/base.css @@ -33,7 +33,8 @@ the app and for the component build. */ @import url("@fontsource/inconsolata/700.css"); @import url("normalize.css/normalize.css") layer(normalize); -@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound); +@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") +layer(compound); @import url("@vector-im/compound-web/dist/style.css") layer(compound.components); :root { @@ -76,6 +77,15 @@ the app and for the component build. */ --video-tile-background: var(--cpd-color-bg-subtle-secondary); } +/* The breakpoints in Element Call's stylesheets are container queries against +this element rather than media queries against the viewport. For the standalone +app the two are the same thing, since the root is the page; for a host that +embeds Element Call in a corner of its own page they are not, and it is the +corner that the layout has to fit. */ +[data-element-call-root] { + container: element-call / size; +} + .cpd-theme-dark { --cpd-color-border-accent: var(--cpd-color-green-1100); --stopgap-color-on-solid-accent: var(--cpd-color-text-primary); diff --git a/src/button/ReactionToggleButton.module.css b/src/button/ReactionToggleButton.module.css index 705d4d9e..3be0fd35 100644 --- a/src/button/ReactionToggleButton.module.css +++ b/src/button/ReactionToggleButton.module.css @@ -10,7 +10,7 @@ width: fit-content; } -@media (max-width: 420px) { +@container element-call (max-width: 420px) { .reactionPopupMenu { --reaction-button-padding: 8px; --reaction-button-fontsize: 16px; diff --git a/src/components/CallFooter.module.css b/src/components/CallFooter.module.css index d919b33e..e006a5f7 100644 --- a/src/components/CallFooter.module.css +++ b/src/components/CallFooter.module.css @@ -77,7 +77,7 @@ Please see LICENSE in the repository root for full details. } /*First hide the logo*/ -@media (max-width: 750px) { +@container element-call (max-width: 750px) { .logo { display: none; } @@ -94,7 +94,7 @@ Please see LICENSE in the repository root for full details. With the logo hidden >500px is enough space to show overflow, buttons, layout. Once we exceed 500 we hide everything except the buttons. */ -@media (max-width: 500px) { +@container element-call (max-width: 500px) { .footer { grid-template-areas: "buttons buttons buttons"; } @@ -115,27 +115,27 @@ Once we exceed 500 we hide everything except the buttons. } } -@media (max-height: 800px) { +@container element-call (max-height: 800px) { .footer { padding-block: var(--cpd-space-8x) calc(env(safe-area-inset-bottom) + var(--cpd-space-8x)); } } -@media (max-height: 400px) { +@container element-call (max-height: 400px) { .footer { padding-block: var(--cpd-space-4x) calc(env(safe-area-inset-bottom) + var(--cpd-space-4x)); } } -@media (max-width: 370px) { +@container element-call (max-width: 370px) { .shareScreen { display: none; } /* PIP custom css */ - @media (max-height: 400px) { + @container element-call (max-height: 400px) { .shareScreen { display: flex; } @@ -148,13 +148,13 @@ Once we exceed 500 we hide everything except the buttons. } } -@media (max-width: 320px) { +@container element-call (max-width: 320px) { .raiseHand { display: none; } } -@media (min-width: 800px) { +@container element-call (min-width: 800px) { .buttons { gap: var(--cpd-space-4x); } diff --git a/src/grid/OneOnOneMobileLayout.module.css b/src/grid/OneOnOneMobileLayout.module.css index e781726c..d07520f5 100644 --- a/src/grid/OneOnOneMobileLayout.module.css +++ b/src/grid/OneOnOneMobileLayout.module.css @@ -30,7 +30,7 @@ Please see LICENSE in the repository root for full details. block-size: 140px; } -@media (max-width: 600px) { +@container element-call (max-width: 600px) { /* Give the PiP a portrait aspect ratio */ .pip[data-size="sm"] { inline-size: 88px; diff --git a/src/grid/SpotlightExpandedLayout.module.css b/src/grid/SpotlightExpandedLayout.module.css index d765c6fc..570b6266 100644 --- a/src/grid/SpotlightExpandedLayout.module.css +++ b/src/grid/SpotlightExpandedLayout.module.css @@ -25,7 +25,7 @@ Please see LICENSE in the repository root for full details. var(--content-inset-left); } -@media (min-width: 600px) { +@container element-call (min-width: 600px) { .pip { inline-size: 180px; block-size: 135px; diff --git a/src/room/CallEndedView.module.css b/src/room/CallEndedView.module.css index e62e93d0..7b2dbee0 100644 --- a/src/room/CallEndedView.module.css +++ b/src/room/CallEndedView.module.css @@ -74,7 +74,7 @@ Please see LICENSE in the repository root for full details. margin-bottom: 44px; } -@media (min-width: 800px) { +@container element-call (min-width: 800px) { .logo { display: none; } diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 736a915a..3c393cd6 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -75,7 +75,7 @@ spotlight tile is maximised and displaying video, apply a gradient background. * background: none; } -@media (max-width: 320px) { +@container element-call (max-width: 320px) { .invite { display: none; } diff --git a/src/room/LobbyView.module.css b/src/room/LobbyView.module.css index b66d483c..b112cdf2 100644 --- a/src/room/LobbyView.module.css +++ b/src/room/LobbyView.module.css @@ -28,13 +28,13 @@ Please see LICENSE in the repository root for full details. color: var(--cpd-color-theme-primary) !important; } -@media (max-width: 500px) { +@container element-call (max-width: 500px) { .join { width: 100%; } } -@media (min-height: 650px) { +@container element-call (min-height: 650px) { .content { gap: var(--cpd-space-10x); } diff --git a/src/room/VideoPreview.module.css b/src/room/VideoPreview.module.css index 67eae10b..44c43faf 100644 --- a/src/room/VideoPreview.module.css +++ b/src/room/VideoPreview.module.css @@ -71,7 +71,7 @@ video.mirror { ); } -@media (max-width: 550px) { +@container element-call (max-width: 550px) { .preview { margin-inline: 0; border-radius: 0; From 489c1af4265a6a053e21be36c4ae38d913caf7c7 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Mon, 7 Sep 2026 17:35:25 +0200 Subject: [PATCH 058/154] test resizing via @container --- playwright/component/component-call.spec.ts | 41 ++++++++++++--------- playwright/component/harness.ts | 16 ++++++++ 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index 784158ad..54e1a22a 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -7,7 +7,12 @@ Please see LICENSE in the repository root for full details. import { expect, type Locator, type Page, test } from "@playwright/test"; -import { createUserAndRoom, expectWithin, startHarness } from "./harness.ts"; +import { + createUserAndRoom, + expectWithin, + resizeContainer, + startHarness, +} from "./harness.ts"; import { SpaHelpers } from "../spa-helpers.ts"; /** @@ -58,6 +63,10 @@ test("keeps its modals inside the container it was given", async ({ page }) => { const pane = panes.first(); const container = pane.getByTestId("call-container"); + // In the flat container the harness gives it by default, Element Call hides + // its controls a few seconds after the call starts, as it would in a flat + // window. A full-size container keeps them on screen to be clicked. + await resizeContainer(container, { width: 900, height: 640 }); await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); await expect(pane.getByTestId("footer-container")).toBeVisible({ timeout: 60_000, @@ -151,19 +160,10 @@ test("lays itself out for the space it is given, not the page", async ({ // a host wanting a picture-in-picture made the iframe small, and Element Call // saw the window shrink. A component gets no such signal from the window, // which stays as large as it ever was; only the container changes. - const resize = async (width: number, height: number): Promise => - container.evaluate( - (element, size) => { - element.style.width = `${size.width}px`; - element.style.height = `${size.height}px`; - }, - { width, height }, - ); - - await resize(300, 300); + await resizeContainer(container, { width: 300, height: 300 }); await expect(call).toHaveAttribute("data-layout", "pip"); - await resize(900, 700); + await resizeContainer(container, { width: 900, height: 700 }); await expect(call).not.toHaveAttribute("data-layout", "pip"); }); @@ -181,12 +181,14 @@ async function callShape(scope: Page | Locator): Promise<{ const call = scope.locator("[data-layout]"); const footer = scope.getByTestId("footer-container"); await expect(footer).toBeVisible(); + // The tile arrives with the media connection, which can take a while const tile = scope.getByTestId("videoTile").first(); - await expect(tile).toBeVisible(); + await expect(tile).toBeVisible({ timeout: 60_000 }); const tileBox = (await tile.boundingBox())!; const footerBox = (await footer.boundingBox())!; + // Buttons and switches alike: the mute controls are switches const buttons = await footer - .getByRole("button") + .locator("button") .filter({ visible: true }) .evaluateAll((elements) => elements.map((element) => element.getAttribute("aria-label")), @@ -228,10 +230,7 @@ test("looks the same in a small container as in a small window", async ({ const panes = await startHarness(page, username, roomId); const pane = panes.first(); const container = pane.getByTestId("call-container"); - await container.evaluate((element, { width, height }) => { - element.style.width = `${width}px`; - element.style.height = `${height}px`; - }, size); + await resizeContainer(container, size); await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 }); await expect(pane.locator("[data-layout]")).toBeVisible({ timeout: 60_000 }); const component = await callShape(pane); @@ -244,4 +243,10 @@ test("looks the same in a small container as in a small window", async ({ // small container gets the compact footer a small window does, rather than // the full-width one the window's own size would call for expect(component).toEqual(reference); + // And that footer is the compact one: a single row of controls, not the + // full-height bar with its logo and layout switch that a large window gets + expect(component.footerHeight).toBeLessThan(size.height / 3); + expect(component.tileHeight + component.footerHeight).toBeLessThanOrEqual( + size.height, + ); }); diff --git a/playwright/component/harness.ts b/playwright/component/harness.ts index 4602f22d..8e1a6e58 100644 --- a/playwright/component/harness.ts +++ b/playwright/component/harness.ts @@ -108,3 +108,19 @@ export async function expectWithin( outerBox.y + outerBox.height + slack, ); } + +/** + * Gives one of the harness's containers a new size. Element Call lays itself + * out for the size of its container, so this is how a test puts it into a + * particular mode: a flat or narrow one, a picture-in-picture, or a full-size + * window, without the window itself changing at all. + */ +export async function resizeContainer( + container: Locator, + size: { width: number; height: number }, +): Promise { + await container.evaluate((element, { width, height }) => { + element.style.width = `${width}px`; + element.style.height = `${height}px`; + }, size); +} From 27734a1a029a2be3aa71b548ebec78ca68e50ad7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:13:17 +0000 Subject: [PATCH 059/154] Update all non-major dependencies --- package.json | 4 +- pnpm-lock.yaml | 2908 ++++++++++++++++++++++++------------------------ 2 files changed, 1452 insertions(+), 1460 deletions(-) diff --git a/package.json b/package.json index 2f4faa60..bb28b67c 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "@vitejs/plugin-react": "^6.0.2", "@vitest/browser-playwright": "^4.1.5", "@vitest/coverage-v8": "^4.0.18", - "@vitest/ui": "4.1.10", + "@vitest/ui": "4.1.11", "babel-plugin-react-compiler": "^1.0.0", "classnames": "^2.3.1", "copy-to-clipboard": "^3.3.3", @@ -101,7 +101,7 @@ "node-stdlib-browser": "^1.3.1", "normalize.css": "^8.0.1", "observable-hooks": "^4.2.3", - "oxfmt": "^0.61.0", + "oxfmt": "^0.66.0", "oxlint": "^1.70.0", "oxlint-tsgolint": "^0.25.0", "pako": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d5550f4..3d35a87f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: devDependencies: '@codecov/vite-plugin': specifier: ^1.3.0 - version: 1.9.1(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 1.9.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) '@fontsource/inconsolata': specifier: ^5.1.0 version: 5.3.0 @@ -51,22 +51,22 @@ importers: version: 0.10.35 '@playwright/test': specifier: ^1.60.0 - version: 1.62.0 + version: 1.62.1 '@radix-ui/react-dialog': specifier: ^1.0.4 - version: 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-slider': specifier: ^1.1.2 - version: 1.4.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.4.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-visually-hidden': specifier: ^1.0.3 - version: 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.2.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@react-spring/web': specifier: ^10.0.0 version: 10.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@rolldown/plugin-babel': specifier: ^0.2.3 - version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) '@sentry/react': specifier: ^8.0.0 version: 8.55.2(react@19.2.8) @@ -75,13 +75,13 @@ importers: version: 3.6.1(supports-color@7.2.0) '@storybook/addon-docs': specifier: ^10.3.6 - version: 10.5.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.60.1)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 10.6.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8)) '@storybook/addon-vitest': specifier: ^10.3.6 - version: 10.5.5(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(vitest@4.1.10) + version: 10.6.0(@vitest/browser-playwright@4.1.11)(@vitest/browser@4.1.11)(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8))(vitest@4.1.11) '@storybook/react-vite': specifier: ^10.3.6 - version: 10.5.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.60.1)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 10.6.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.60.1)(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) '@testing-library/dom': specifier: ^10.1.0 version: 10.4.1 @@ -90,10 +90,10 @@ importers: version: 6.9.1 '@testing-library/react': specifier: ^16.0.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@testing-library/user-event': specifier: ^14.5.1 - version: 14.6.1(@testing-library/dom@10.4.1) + version: 14.6.7(@testing-library/dom@10.4.1) '@types/eslint': specifier: ^9.6.1 version: 9.6.1 @@ -117,37 +117,37 @@ importers: version: 1.5.6 '@types/react': specifier: ^19.0.0 - version: 19.2.17 + version: 19.2.18 '@types/react-dom': specifier: ^19.0.0 - version: 19.2.3(@types/react@19.2.17) + version: 19.2.5(@types/react@19.2.18) '@types/sdp-transform': specifier: ^2.4.5 version: 2.15.0 '@typescript-eslint/utils': specifier: ^8.61.0 - version: 8.65.0(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + version: 8.69.0(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) '@use-gesture/react': specifier: ^10.2.11 version: 10.3.1(react@19.2.8) '@vector-im/compound-design-tokens': specifier: ^10.0.0 - version: 10.2.4(@types/react@19.2.17)(react@19.2.8) + version: 10.2.4(@types/react@19.2.18)(react@19.2.8) '@vector-im/compound-web': specifier: ^10.0.0 - version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.17)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) '@vitest/browser-playwright': specifier: ^4.1.5 - version: 4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10) + version: 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11) '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) + version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) '@vitest/ui': - specifier: 4.1.10 - version: 4.1.10(vitest@4.1.10) + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -162,19 +162,19 @@ importers: version: link:eslint eslint-plugin-storybook: specifier: ^10.3.6 - version: 10.5.5(eslint@8.57.1(supports-color@7.2.0))(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3) + version: 10.6.0(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) fetch-mock: specifier: 11.1.5 version: 11.1.5(node-fetch@2.7.0) i18next: specifier: ^26.0.0 - version: 26.3.6(typescript@5.9.3) + version: 26.4.1(typescript@5.9.3) i18next-browser-languagedetector: specifier: ^8.0.0 version: 8.2.1 i18next-cli: specifier: ^1.61.0 - version: 1.67.3(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(typescript@5.9.3) + version: 1.72.4(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(typescript@5.9.3) jsdom: specifier: ^26.0.0 version: 26.1.0(supports-color@7.2.0) @@ -206,11 +206,11 @@ importers: specifier: ^4.2.3 version: 4.2.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rxjs@7.8.2) oxfmt: - specifier: ^0.61.0 - version: 0.61.0 + specifier: ^0.66.0 + version: 0.66.0 oxlint: specifier: ^1.70.0 - version: 1.76.0(oxlint-tsgolint@0.25.0) + version: 1.81.0(oxlint-tsgolint@0.25.0) oxlint-tsgolint: specifier: ^0.25.0 version: 0.25.0 @@ -219,10 +219,10 @@ importers: version: 2.2.0 postcss: specifier: ^8.4.41 - version: 8.5.25 + version: 8.5.26 postcss-preset-env: specifier: ^10.0.0 - version: 10.6.1(postcss@8.5.25) + version: 10.6.1(postcss@8.5.26) posthog-js: specifier: 1.408.2 version: 1.408.2 @@ -237,10 +237,10 @@ importers: version: 19.2.8(react@19.2.8) react-i18next: specifier: ^16.0.0 <16.7.0 - version: 16.6.6(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + version: 16.6.6(i18next@26.4.1(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) react-router-dom: specifier: ^7.0.0 - version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react-use-measure: specifier: ^2.1.1 version: 2.1.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -249,10 +249,10 @@ importers: version: 7.8.2 sass: specifier: ^1.42.1 - version: 1.102.0 + version: 1.103.1 storybook: specifier: ^10.3.6 - version: 10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8) + version: 10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8) typescript: specifier: ^5.8.3 version: 5.9.3 @@ -261,37 +261,37 @@ importers: version: 4.7.1 uuid: specifier: ^14.0.0 - version: 14.0.1 + version: 14.0.2 vaul: specifier: ^1.0.0 - version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) vite: specifier: ^8.0.0 - version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + version: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) vite-plugin-generate-file: specifier: ^0.3.0 version: 0.3.1 vite-plugin-html: specifier: ^3.2.2 - version: 3.2.2(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 3.2.2(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) vite-plugin-node-polyfills: specifier: ^0.28.0 - version: 0.28.0(rollup@4.60.1)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 0.28.0(rollup@4.60.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) vite-plugin-node-stdlib-browser: specifier: ^0.2.1 - version: 0.2.1(node-stdlib-browser@1.3.1)(rollup@4.60.1)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 0.2.1(node-stdlib-browser@1.3.1)(rollup@4.60.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) vite-plugin-svgr: specifier: ^4.0.0 - version: 4.5.0(rollup@4.60.1)(supports-color@7.2.0)(typescript@5.9.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 4.5.0(rollup@4.60.1)(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) vite-plugin-wasm: specifier: ^3.6.0 - version: 3.6.0(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 3.6.0(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) vitest: specifier: ^4.1.5 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) vitest-axe: specifier: ^1.0.0-pre.3 - version: 1.0.0-pre.5(vitest@4.1.10) + version: 1.0.0-pre.5(vitest@4.1.11) packages: @@ -728,8 +728,8 @@ packages: peerDependencies: postcss: ^8.4 - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.0': + resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} @@ -737,8 +737,8 @@ packages: '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.0': + resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} @@ -752,158 +752,158 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -993,12 +993,12 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead - '@inquirer/ansi@2.0.7': - resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + '@inquirer/ansi@2.0.8': + resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/checkbox@5.2.1': - resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + '@inquirer/checkbox@5.2.4': + resolution: {integrity: sha512-oKT5RNeO9oKYizSyOxKb66IrKUsYVMQD2tnjllYW4wmSOe9WawfL3OE2p82JuvAyo+/nDIEem5P4Bm/ZIVYVew==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1006,8 +1006,8 @@ packages: '@types/node': optional: true - '@inquirer/confirm@6.1.1': - resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + '@inquirer/confirm@6.3.1': + resolution: {integrity: sha512-HvnOHal39DTenOVoRpIy8Z+n4YYfNa3Qhi/P8zFqn9/d1crpmQG0DPx34rwOeOFvotgKr9Vov/14OmKR/Iwfjw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1015,8 +1015,8 @@ packages: '@types/node': optional: true - '@inquirer/core@11.2.1': - resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + '@inquirer/core@12.0.2': + resolution: {integrity: sha512-9pBhkxE14uUlnHhs8lOt7qVPtS4caRY6CjADl8COejl9Mf7w8i6Uoe3DrljCqYtmYM3Iv2Q6EmPxx/oTYbvTAQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1024,8 +1024,8 @@ packages: '@types/node': optional: true - '@inquirer/editor@5.2.2': - resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + '@inquirer/editor@5.3.2': + resolution: {integrity: sha512-7ruKO5d/wxhGHxdCQ3eyP/aBNrPTZ+Ju7ERtnAwuIsu0RSLs6kotlTRkOxHatbD6sDaEEdwsD82pzFiT9ESIyQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1033,8 +1033,8 @@ packages: '@types/node': optional: true - '@inquirer/expand@5.1.1': - resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + '@inquirer/expand@5.1.4': + resolution: {integrity: sha512-12o4aYnjWouoq0fyVhYIL+hwXAaga+kntA/wrijFmVvn2heinkdvkbEXRt1Ob/9IOJ285US5tntjfI/Ukt2YkA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1042,8 +1042,8 @@ packages: '@types/node': optional: true - '@inquirer/external-editor@3.0.3': - resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + '@inquirer/external-editor@3.0.5': + resolution: {integrity: sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1051,12 +1051,12 @@ packages: '@types/node': optional: true - '@inquirer/figures@2.0.7': - resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + '@inquirer/figures@2.0.9': + resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/input@5.1.2': - resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + '@inquirer/input@5.1.5': + resolution: {integrity: sha512-2MkYYFD1Owt1eRBwHhsLSutysRodWasA4DVpl42KJKGmzwBTXRYAIXujisrt0UW0UohUlMjNjLfMDbw4emRbfw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1064,8 +1064,8 @@ packages: '@types/node': optional: true - '@inquirer/number@4.1.1': - resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + '@inquirer/number@4.2.2': + resolution: {integrity: sha512-tjW1gMIQsFb/9Ie11i8SpuPARBdyCVfC5XOeVugWovrdm8CpjzN+A1CBX5QwCCN2mtNmpgDDCnuLCCT94cQ5zA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1073,8 +1073,8 @@ packages: '@types/node': optional: true - '@inquirer/password@5.1.1': - resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + '@inquirer/password@5.2.1': + resolution: {integrity: sha512-InA6nJxMCXPavPBiTKYHIsYiWz/mkIsk1rkI6pKp5w18gVawqrZOj7eSzSmPJa3j0pVxXm6RlrFP1BE9YeOixA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1082,8 +1082,8 @@ packages: '@types/node': optional: true - '@inquirer/prompts@8.5.2': - resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + '@inquirer/prompts@8.7.1': + resolution: {integrity: sha512-xJIKWyrFNUKj3R5VEub/iKodG9Sh/MbCeApZUbATJgHTw8YoAJ2gOT78HlvO6NMFiRvjm6ySM7aiAew/zVY0Sw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1091,8 +1091,8 @@ packages: '@types/node': optional: true - '@inquirer/rawlist@5.3.1': - resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + '@inquirer/rawlist@5.3.4': + resolution: {integrity: sha512-c4fDwOpQsjJDHjXdHJE1/xXH6w9/BAiyu3QKzH9Ww5Xm1q37mzyUNa20L7qdtM1ECi4e+oUEKjhK8hR37OMAMQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1100,8 +1100,8 @@ packages: '@types/node': optional: true - '@inquirer/search@4.2.1': - resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + '@inquirer/search@4.3.2': + resolution: {integrity: sha512-YIaEhWmfkdHEWOKwhF/oJar35FFTTrAdzNbJDo9lSsRT6E/obRPx9oVm4rkTMtlcJSqGYttNtLbcHQef9Qw2yw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1109,8 +1109,8 @@ packages: '@types/node': optional: true - '@inquirer/select@5.2.1': - resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + '@inquirer/select@5.2.4': + resolution: {integrity: sha512-L9ubNaJdoBsiom/AfPKq0QLHTkhhCFCtszzdVidGremrlJjPZfGlmvp+IULKIbnYjtVFWnvb4mhiEdNTZ12ugA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1118,8 +1118,8 @@ packages: '@types/node': optional: true - '@inquirer/type@4.0.7': - resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + '@inquirer/type@4.1.1': + resolution: {integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -1156,6 +1156,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -1210,12 +1213,12 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.2.2': - resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + '@napi-rs/wasm-runtime@1.2.3': + resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1411,16 +1414,16 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@oxc-project/types@0.139.0': - resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} '@oxc-resolver/binding-android-arm-eabi@11.19.1': resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} cpu: [arm] os: [android] - '@oxc-resolver/binding-android-arm-eabi@11.24.2': - resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + '@oxc-resolver/binding-android-arm-eabi@11.21.2': + resolution: {integrity: sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==} cpu: [arm] os: [android] @@ -1429,8 +1432,8 @@ packages: cpu: [arm64] os: [android] - '@oxc-resolver/binding-android-arm64@11.24.2': - resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + '@oxc-resolver/binding-android-arm64@11.21.2': + resolution: {integrity: sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==} cpu: [arm64] os: [android] @@ -1439,8 +1442,8 @@ packages: cpu: [arm64] os: [darwin] - '@oxc-resolver/binding-darwin-arm64@11.24.2': - resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + '@oxc-resolver/binding-darwin-arm64@11.21.2': + resolution: {integrity: sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==} cpu: [arm64] os: [darwin] @@ -1449,8 +1452,8 @@ packages: cpu: [x64] os: [darwin] - '@oxc-resolver/binding-darwin-x64@11.24.2': - resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + '@oxc-resolver/binding-darwin-x64@11.21.2': + resolution: {integrity: sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==} cpu: [x64] os: [darwin] @@ -1459,8 +1462,8 @@ packages: cpu: [x64] os: [freebsd] - '@oxc-resolver/binding-freebsd-x64@11.24.2': - resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + '@oxc-resolver/binding-freebsd-x64@11.21.2': + resolution: {integrity: sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==} cpu: [x64] os: [freebsd] @@ -1469,8 +1472,8 @@ packages: cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': - resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2': + resolution: {integrity: sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==} cpu: [arm] os: [linux] @@ -1479,8 +1482,8 @@ packages: cpu: [arm] os: [linux] - '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': - resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + '@oxc-resolver/binding-linux-arm-musleabihf@11.21.2': + resolution: {integrity: sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==} cpu: [arm] os: [linux] @@ -1490,8 +1493,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': - resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + '@oxc-resolver/binding-linux-arm64-gnu@11.21.2': + resolution: {integrity: sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==} cpu: [arm64] os: [linux] libc: [glibc] @@ -1502,8 +1505,8 @@ packages: os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-arm64-musl@11.24.2': - resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + '@oxc-resolver/binding-linux-arm64-musl@11.21.2': + resolution: {integrity: sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==} cpu: [arm64] os: [linux] libc: [musl] @@ -1514,8 +1517,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': - resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + '@oxc-resolver/binding-linux-ppc64-gnu@11.21.2': + resolution: {integrity: sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==} cpu: [ppc64] os: [linux] libc: [glibc] @@ -1526,8 +1529,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': - resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + '@oxc-resolver/binding-linux-riscv64-gnu@11.21.2': + resolution: {integrity: sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==} cpu: [riscv64] os: [linux] libc: [glibc] @@ -1538,8 +1541,8 @@ packages: os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': - resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + '@oxc-resolver/binding-linux-riscv64-musl@11.21.2': + resolution: {integrity: sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==} cpu: [riscv64] os: [linux] libc: [musl] @@ -1550,8 +1553,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': - resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + '@oxc-resolver/binding-linux-s390x-gnu@11.21.2': + resolution: {integrity: sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==} cpu: [s390x] os: [linux] libc: [glibc] @@ -1562,8 +1565,8 @@ packages: os: [linux] libc: [glibc] - '@oxc-resolver/binding-linux-x64-gnu@11.24.2': - resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + '@oxc-resolver/binding-linux-x64-gnu@11.21.2': + resolution: {integrity: sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==} cpu: [x64] os: [linux] libc: [glibc] @@ -1574,8 +1577,8 @@ packages: os: [linux] libc: [musl] - '@oxc-resolver/binding-linux-x64-musl@11.24.2': - resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + '@oxc-resolver/binding-linux-x64-musl@11.21.2': + resolution: {integrity: sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==} cpu: [x64] os: [linux] libc: [musl] @@ -1585,8 +1588,8 @@ packages: cpu: [arm64] os: [openharmony] - '@oxc-resolver/binding-openharmony-arm64@11.24.2': - resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + '@oxc-resolver/binding-openharmony-arm64@11.21.2': + resolution: {integrity: sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==} cpu: [arm64] os: [openharmony] @@ -1595,8 +1598,8 @@ packages: engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-resolver/binding-wasm32-wasi@11.24.2': - resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + '@oxc-resolver/binding-wasm32-wasi@11.21.2': + resolution: {integrity: sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==} engines: {node: '>=14.0.0'} cpu: [wasm32] @@ -1605,8 +1608,8 @@ packages: cpu: [arm64] os: [win32] - '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': - resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + '@oxc-resolver/binding-win32-arm64-msvc@11.21.2': + resolution: {integrity: sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==} cpu: [arm64] os: [win32] @@ -1620,129 +1623,129 @@ packages: cpu: [x64] os: [win32] - '@oxc-resolver/binding-win32-x64-msvc@11.24.2': - resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + '@oxc-resolver/binding-win32-x64-msvc@11.21.2': + resolution: {integrity: sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==} cpu: [x64] os: [win32] - '@oxfmt/binding-android-arm-eabi@0.61.0': - resolution: {integrity: sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA==} + '@oxfmt/binding-android-arm-eabi@0.66.0': + resolution: {integrity: sha512-2Me9eoptv6ERdEuI2P8AOlYdHHraXebJaM6SC0kc2Dfb+mLrep2db+fedBPKaYn673h/vBgvP4tkOdAbaudX6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.61.0': - resolution: {integrity: sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw==} + '@oxfmt/binding-android-arm64@0.66.0': + resolution: {integrity: sha512-u7O+bSSF0HGsDKkQQxBqvLGVepu93RA+JKu+ONqvfh4sCnCEbj31wZj4iG5gk3XfRwrmYj0/8catkO2LcblQKQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.61.0': - resolution: {integrity: sha512-7l8+5ov4BGwtAcmpzvEik/TG3bciwyw/S3e6j5GKH7pcQqcgCVxD3AuJeP6upto+SOTBKQ4wrrdbMt0gq8fHSQ==} + '@oxfmt/binding-darwin-arm64@0.66.0': + resolution: {integrity: sha512-/ikyMIVjX/sdo7KtjxoEsSUosfPzveVhT9RWMx9yGqFDKFJ89JAEKuEeLBmurDjrkb4w8tOnAdSO3SBaplY3bw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.61.0': - resolution: {integrity: sha512-Fnz4dDDXBb7udk+DmwelNjxbD6yptyxwCqwCH2ebo4RVLxVsRfFsn/AHJC49KIltPrVokamGv4SSOsiV50DTxQ==} + '@oxfmt/binding-darwin-x64@0.66.0': + resolution: {integrity: sha512-q5xUsKeFqawa9NXa6ZGXWimFV19m8MogKPdTaSVDAAk2EQKBmBZRDeluwcl1p8ty/OFc9s9888OKEh3xfPVH0g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.61.0': - resolution: {integrity: sha512-mddOebKNCP+AucmzfNsk3jgbr681qAUvgMqi865GW5gWLJ/AnzXbvjQRrny0e++NAN8aphav/aRSrfFxNsNjpA==} + '@oxfmt/binding-freebsd-x64@0.66.0': + resolution: {integrity: sha512-CR+x4VzMY0pRXLK/xFQ/RzsSFkP5t2Z2mef0QY6OP/rTRcMUoMLCOM62/3Fp/t0K+UDoBKxvMyeb6D0zPMjleA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.61.0': - resolution: {integrity: sha512-svx59iYL+DbaZGZUIoice4W0CjRXGExnbz7Re+awIb60rVxBS2KrU7Hnlx+nZYanLGLpjneUEgo/VFEKkSZAyQ==} + '@oxfmt/binding-linux-arm-gnueabihf@0.66.0': + resolution: {integrity: sha512-ZEYmO/LbH9tTQCADILHGZE4GeOXOAj2VzedHkASNwjmwlwtutJCLpCJbIs37wRGTFgWRoEcD72jpMX+IBJUGjQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.61.0': - resolution: {integrity: sha512-BYK9MPJPCf6d+fLKMTruThmEyCtHzQ1zLcsrTlUVkmnoXIaHAbfpeLYQwX1tkjs7W11dyzoi6HFvKcdnvX1zNg==} + '@oxfmt/binding-linux-arm-musleabihf@0.66.0': + resolution: {integrity: sha512-hNtR9/oU0CeTkq7JnRkmBQwqe17v2ZaAMLC4VcN7IIOWeRyWDk0knSPWS9iiLmtbZ2RRBBtsG01jQgkZmKCJeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.61.0': - resolution: {integrity: sha512-QUaCNLq2/EC6G5ljOuFanl9Lgw6ZWp4co7rs4+KOMUzbGfA4Lq58FHRjjF9sVIG+93XSbo343MxFATrOU1qctA==} + '@oxfmt/binding-linux-arm64-gnu@0.66.0': + resolution: {integrity: sha512-uwOVQ8i6I1LT/+eDzfsgrrcZp8Fn6NPVUPn8fF5gdFGekFf0PddF+LEuwsD0/pbNUcKZhDj2rQ5UpITh9gF4iQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.61.0': - resolution: {integrity: sha512-S6uvJ6MXnRXl+zTs0CARNDvkE+cymj0EVWEKKsyKnlLlqTyQJBjw5s4D2pSIOZc+S46cy4STefzcr/sm0VzVPA==} + '@oxfmt/binding-linux-arm64-musl@0.66.0': + resolution: {integrity: sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.61.0': - resolution: {integrity: sha512-6VDlRcytvZG6UlSIdAFKDLbppo9tvPxrWzle6vHldYFMeuDPQEfMKrkwezp7FaBq1wik9ra554ZZeRPsyIkFpg==} + '@oxfmt/binding-linux-ppc64-gnu@0.66.0': + resolution: {integrity: sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.61.0': - resolution: {integrity: sha512-KkBTYbzExpbmn15XjKPLu2fRV2PVlq+KWt+brad5rwIa03vdYoaDRWiS7raHII/dCTR6Ro4UpYUCH4t6lif4WQ==} + '@oxfmt/binding-linux-riscv64-gnu@0.66.0': + resolution: {integrity: sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.61.0': - resolution: {integrity: sha512-69tzIq7sJLVB9dxYYtvMzcSSsnZHSO+U2U19O2RqDqgj6+Q4O7HjSXdaszbcgqzhsUwzSH7z5kWvk8nmf6BHTg==} + '@oxfmt/binding-linux-riscv64-musl@0.66.0': + resolution: {integrity: sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.61.0': - resolution: {integrity: sha512-Oqi/N0OvtOVXsPKAOOhKgGH3msRYF8BLJaNBbWiupRiKoKVyc8JRKPCfarkQJC+RgP9U8raUKLe+bNwd0HUMiA==} + '@oxfmt/binding-linux-s390x-gnu@0.66.0': + resolution: {integrity: sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.61.0': - resolution: {integrity: sha512-3TKwv/ed4uwJSemAA8P9XcoqETpjQI4waquF9UilhA9Mn/dhr1PdUEXWlL74mtc6ZNfmKPA9+NEJm01nRF8CVA==} + '@oxfmt/binding-linux-x64-gnu@0.66.0': + resolution: {integrity: sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.61.0': - resolution: {integrity: sha512-uFso4u4nLkVSlMCpgjyvWV60Gt7GvDQHnk1mmRxHIkZTMB0ljpUKwCD9FYGgN9H97x2wYl0UwEjgRZaPIuhEhw==} + '@oxfmt/binding-linux-x64-musl@0.66.0': + resolution: {integrity: sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.61.0': - resolution: {integrity: sha512-keGLkzeOvkMpNmPp4hffXWpfoSsY6e1K8++KXD4mSSfxdvM8q9QUDsYY689TB1k6Co832DZn1MnaaVx6cIBMWQ==} + '@oxfmt/binding-openharmony-arm64@0.66.0': + resolution: {integrity: sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.61.0': - resolution: {integrity: sha512-VzsAISkFxmNhJ5LBDEL9VuH6tJsVJMtqYit2LyIUf/HLnsCe4Pg9SMOjjVQzGWt0bnpyfJ94CrqTqcpNZzK+ug==} + '@oxfmt/binding-win32-arm64-msvc@0.66.0': + resolution: {integrity: sha512-soV+0vESv7e5ntCHWC61x4gg8OSak6IHHnWsZmHrJFlvMj2AK+kmldErCNkVkrvc1Ts2/++rJXn+IuAb2WMXhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.61.0': - resolution: {integrity: sha512-xv4t7yzwJoYaLB6Zv28B3W+j7brEjsyv50rLTAQgmxJzddce9fAMCxed8dSAkbWES0zz2J29nYK5FaTuD2YBHg==} + '@oxfmt/binding-win32-ia32-msvc@0.66.0': + resolution: {integrity: sha512-YCPi23uRIEYuIKTZohAkKbPFpujQ5QBuUM5iDv+UqbCmTPAkaFsxjsSuB8xlBpRT0G7eP/4HMF+cPDSqHtOD9A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.61.0': - resolution: {integrity: sha512-6EZXFkqOwxdDYjIn3TSNnPk3ST5E5GiYd4FiM6UF/mCL/LZSfr6D6UygTfW3R1PCQP2quCKpCEGRlij8E3VYbg==} + '@oxfmt/binding-win32-x64-msvc@0.66.0': + resolution: {integrity: sha512-bwTQcv/JVRPkOqQtMF0X7vpvpncDQiBcXHxZ9S2hR12Hlo8bvBdUR5x5XnxzDZ3kM0qoZw1rv7KaD66Ly+pFWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1777,124 +1780,124 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.76.0': - resolution: {integrity: sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w==} + '@oxlint/binding-android-arm-eabi@1.81.0': + resolution: {integrity: sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.76.0': - resolution: {integrity: sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA==} + '@oxlint/binding-android-arm64@1.81.0': + resolution: {integrity: sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.76.0': - resolution: {integrity: sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q==} + '@oxlint/binding-darwin-arm64@1.81.0': + resolution: {integrity: sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.76.0': - resolution: {integrity: sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw==} + '@oxlint/binding-darwin-x64@1.81.0': + resolution: {integrity: sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.76.0': - resolution: {integrity: sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg==} + '@oxlint/binding-freebsd-x64@1.81.0': + resolution: {integrity: sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.76.0': - resolution: {integrity: sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ==} + '@oxlint/binding-linux-arm-gnueabihf@1.81.0': + resolution: {integrity: sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.76.0': - resolution: {integrity: sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg==} + '@oxlint/binding-linux-arm-musleabihf@1.81.0': + resolution: {integrity: sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.76.0': - resolution: {integrity: sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw==} + '@oxlint/binding-linux-arm64-gnu@1.81.0': + resolution: {integrity: sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.76.0': - resolution: {integrity: sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw==} + '@oxlint/binding-linux-arm64-musl@1.81.0': + resolution: {integrity: sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.76.0': - resolution: {integrity: sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw==} + '@oxlint/binding-linux-ppc64-gnu@1.81.0': + resolution: {integrity: sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.76.0': - resolution: {integrity: sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA==} + '@oxlint/binding-linux-riscv64-gnu@1.81.0': + resolution: {integrity: sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.76.0': - resolution: {integrity: sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ==} + '@oxlint/binding-linux-riscv64-musl@1.81.0': + resolution: {integrity: sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.76.0': - resolution: {integrity: sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ==} + '@oxlint/binding-linux-s390x-gnu@1.81.0': + resolution: {integrity: sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.76.0': - resolution: {integrity: sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg==} + '@oxlint/binding-linux-x64-gnu@1.81.0': + resolution: {integrity: sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.76.0': - resolution: {integrity: sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A==} + '@oxlint/binding-linux-x64-musl@1.81.0': + resolution: {integrity: sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.76.0': - resolution: {integrity: sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ==} + '@oxlint/binding-openharmony-arm64@1.81.0': + resolution: {integrity: sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.76.0': - resolution: {integrity: sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw==} + '@oxlint/binding-win32-arm64-msvc@1.81.0': + resolution: {integrity: sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.76.0': - resolution: {integrity: sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ==} + '@oxlint/binding-win32-ia32-msvc@1.81.0': + resolution: {integrity: sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.76.0': - resolution: {integrity: sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA==} + '@oxlint/binding-win32-x64-msvc@1.81.0': + resolution: {integrity: sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -1985,8 +1988,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@playwright/test@1.62.0': - resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} engines: {node: '>=20'} hasBin: true @@ -2411,97 +2414,98 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@rolldown/binding-android-arm64@1.1.5': - resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.5': - resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.5': - resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.5': - resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': - resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.5': - resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.5': - resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.5': - resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.5': - resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.5': - resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.5': - resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.5': - resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.5': - resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@rolldown/binding-win32-arm64-msvc@1.1.5': - resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.5': - resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2799,22 +2803,22 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-docs@10.5.5': - resolution: {integrity: sha512-0YpKlimS4XE0kQ8Maa5coeefQxdyDrBHg1wOP3WTPuBe4FolFSCDveR0ge2+vuUBk+fZfn2+l+3Q2jmAWaRGDg==} + '@storybook/addon-docs@10.6.0': + resolution: {integrity: sha512-Fk3rUQWeGyCfL170OkmRp6Ruyv7MHPb/xrKa6YNk7FlGmlVLi5tKzh2T/0J13vWEy8wiu2MV3IBu8iRsaUR+Cw==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.5 + storybook: ^10.6.0 peerDependenciesMeta: '@types/react': optional: true - '@storybook/addon-vitest@10.5.5': - resolution: {integrity: sha512-Ymq9ErkSkYiIDuqpJ2+hE5GCQ5J6TCLOWhutqArvwaeAO+HAibM82XNExpJ1/kvPqk9y961GDPkv2W15I88JIw==} + '@storybook/addon-vitest@10.6.0': + resolution: {integrity: sha512-GY2SwSXLNgyMqjQ59J7YTkEQTHdEbBMggOvbbuhuPUBhWo97OGSDCmn1UW3nXDZvkoWyWhF9T9628m1uei7FbA==} peerDependencies: '@vitest/browser': ^3.0.0 || ^4.0.0 '@vitest/browser-playwright': ^4.0.0 '@vitest/runner': ^3.0.0 || ^4.0.0 - storybook: ^10.5.5 + storybook: ^10.6.0 vitest: ^3.0.0 || ^4.0.0 peerDependenciesMeta: '@vitest/browser': @@ -2826,30 +2830,12 @@ packages: vitest: optional: true - '@storybook/builder-vite@10.5.5': - resolution: {integrity: sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==} + '@storybook/builder-vite@10.6.0': + resolution: {integrity: sha512-7zt8tYB6dnSO8fbjUz91MHexGDpf9HrLlA3FAmlVb+2ZL3eWUs499L3I6BDUymcWu7oW5WCBJlB8ERj8ZLLzig==} peerDependencies: - storybook: ^10.5.5 + storybook: ^10.6.0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.5.5': - resolution: {integrity: sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==} - peerDependencies: - esbuild: ^0.28.0 - rollup: '*' - storybook: ^10.5.5 - vite: '*' - webpack: '*' - peerDependenciesMeta: - esbuild: - optional: true - rollup: - optional: true - vite: - optional: true - webpack: - optional: true - '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} @@ -2858,40 +2844,40 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/react-dom-shim@10.5.5': - resolution: {integrity: sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg==} + '@storybook/react-dom-shim@10.6.0': + resolution: {integrity: sha512-ZE0l4xL6d00rKFeJhRDuh0t3r+/2ffhx47lIyaSWrTD5kuM3nDjKDxCIWwX4E3gLV+oNX8E1prXRMuEWJUJwJg==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.5 + storybook: ^10.6.0 peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true - '@storybook/react-vite@10.5.5': - resolution: {integrity: sha512-Uy7VV72kVSkw6aDTAPQupXUeZX5LF6e4zqNvTZ+36qxsXAkaFgw7HPEm7L1tsaRfiV+s9anU7UvX47tfJpYGuQ==} + '@storybook/react-vite@10.6.0': + resolution: {integrity: sha512-nc1dqnmjGOcDJvPCkAn4Hmu1gi7dNHHoibNmlBp0d1adosejELZxuNjHBkPl/KxZjbzP3jljQHnHB/guxd8e2g==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.5 + storybook: ^10.6.0 typescript: '>= 4.9.x' vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - '@storybook/react@10.5.5': - resolution: {integrity: sha512-T2Xj0ey7a9RHU6coYLC0L5lhjcdyhLCs9wNv15FvHvgmrRobkynEV72kq5vGW8tFkahNWI1X9+GZPQ6r8Nm38w==} + '@storybook/react@10.6.0': + resolution: {integrity: sha512-0xfYYPRHcr5ta1hmXJCSezH4JGuUHJ+xVF9f1j+Y/MWEMOQE4wnaKWKsk/HLrDjZkT5hKfvmogt9b0yduP6zEQ==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.5 + storybook: ^10.6.0 typescript: '>= 4.9.x' peerDependenciesMeta: '@types/react': @@ -2969,86 +2955,86 @@ packages: peerDependencies: '@svgr/core': '*' - '@swc/core-darwin-arm64@1.15.47': - resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==} + '@swc/core-darwin-arm64@1.16.2': + resolution: {integrity: sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.15.47': - resolution: {integrity: sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==} + '@swc/core-darwin-x64@1.16.2': + resolution: {integrity: sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.15.47': - resolution: {integrity: sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==} + '@swc/core-linux-arm-gnueabihf@1.16.2': + resolution: {integrity: sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.15.47': - resolution: {integrity: sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==} + '@swc/core-linux-arm64-gnu@1.16.2': + resolution: {integrity: sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [glibc] - '@swc/core-linux-arm64-musl@1.15.47': - resolution: {integrity: sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==} + '@swc/core-linux-arm64-musl@1.16.2': + resolution: {integrity: sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [musl] - '@swc/core-linux-ppc64-gnu@1.15.47': - resolution: {integrity: sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==} + '@swc/core-linux-ppc64-gnu@1.16.2': + resolution: {integrity: sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] libc: [glibc] - '@swc/core-linux-s390x-gnu@1.15.47': - resolution: {integrity: sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==} + '@swc/core-linux-s390x-gnu@1.16.2': + resolution: {integrity: sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==} engines: {node: '>=10'} cpu: [s390x] os: [linux] libc: [glibc] - '@swc/core-linux-x64-gnu@1.15.47': - resolution: {integrity: sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==} + '@swc/core-linux-x64-gnu@1.16.2': + resolution: {integrity: sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [glibc] - '@swc/core-linux-x64-musl@1.15.47': - resolution: {integrity: sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==} + '@swc/core-linux-x64-musl@1.16.2': + resolution: {integrity: sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [musl] - '@swc/core-win32-arm64-msvc@1.15.47': - resolution: {integrity: sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==} + '@swc/core-win32-arm64-msvc@1.16.2': + resolution: {integrity: sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.15.47': - resolution: {integrity: sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==} + '@swc/core-win32-ia32-msvc@1.16.2': + resolution: {integrity: sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.15.47': - resolution: {integrity: sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==} + '@swc/core-win32-x64-msvc@1.16.2': + resolution: {integrity: sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.15.47': - resolution: {integrity: sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==} + '@swc/core@1.16.2': + resolution: {integrity: sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -3070,8 +3056,8 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@testing-library/react@16.3.2': - resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} engines: {node: '>=18'} peerDependencies: '@testing-library/dom': ^10.0.0 @@ -3085,8 +3071,8 @@ packages: '@types/react-dom': optional: true - '@testing-library/user-event@14.6.1': - resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@testing-library/dom': '>=7.21.4' @@ -3172,13 +3158,13 @@ packages: '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + '@types/react-dom@19.2.5': + resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} @@ -3192,41 +3178,41 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@typescript-eslint/project-service@8.65.0': - resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + '@typescript-eslint/project-service@8.69.0': + resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.65.0': - resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + '@typescript-eslint/scope-manager@8.69.0': + resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + '@typescript-eslint/tsconfig-utils@8.69.0': + resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.65.0': - resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + '@typescript-eslint/types@8.69.0': + resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.65.0': - resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + '@typescript-eslint/typescript-estree@8.69.0': + resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.65.0': - resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + '@typescript-eslint/utils@8.69.0': + resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.65.0': - resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + '@typescript-eslint/visitor-keys@8.69.0': + resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.3': @@ -3269,35 +3255,38 @@ packages: '@types/react': optional: true - '@vitejs/plugin-react@6.0.5': - resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 vite: ^8.0.0 peerDependenciesMeta: '@rolldown/plugin-babel': optional: true babel-plugin-react-compiler: optional: true + oxc-transform-react: + optional: true - '@vitest/browser-playwright@4.1.10': - resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==} + '@vitest/browser-playwright@4.1.11': + resolution: {integrity: sha512-riLBxPqwnJ0lWs2DN2WeUfYeKLoAjbP2Xx8cLQdSddzMi20sksIa6K2mPz79DyMZKKVKH2ksOC2yJvtNcZg8cg==} peerDependencies: playwright: '*' - vitest: 4.1.10 + vitest: 4.1.11 - '@vitest/browser@4.1.10': - resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==} + '@vitest/browser@4.1.11': + resolution: {integrity: sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==} peerDependencies: - vitest: 4.1.10 + vitest: 4.1.11 - '@vitest/coverage-v8@4.1.10': - resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + '@vitest/coverage-v8@4.1.11': + resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: - '@vitest/browser': 4.1.10 - vitest: 4.1.10 + '@vitest/browser': 4.1.11 + vitest: 4.1.11 peerDependenciesMeta: '@vitest/browser': optional: true @@ -3305,11 +3294,11 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@4.1.10': - resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} - '@vitest/mocker@4.1.10': - resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3322,31 +3311,31 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.1.10': - resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/runner@4.1.10': - resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} - '@vitest/snapshot@4.1.10': - resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@4.1.10': - resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/ui@4.1.10': - resolution: {integrity: sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==} + '@vitest/ui@4.1.11': + resolution: {integrity: sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==} peerDependencies: - vitest: 4.1.10 + vitest: 4.1.11 '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.1.10': - resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -3384,8 +3373,8 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} engines: {node: '>=12'} ansi-styles@4.3.0: @@ -3428,8 +3417,8 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-types@0.16.1: - resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + ast-types@0.16.3: + resolution: {integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==} engines: {node: '>=4'} ast-v8-to-istanbul@1.0.5: @@ -3646,9 +3635,9 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - commander@14.0.3: - resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} - engines: {node: '>=20'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3793,8 +3782,8 @@ packages: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - default-browser@5.5.0: - resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} engines: {node: '>=18'} define-data-property@1.1.4: @@ -3923,15 +3912,15 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.3.1: - resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -3943,11 +3932,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-plugin-storybook@10.5.5: - resolution: {integrity: sha512-xGCrddoZ8pMmwc7M5mp+habWbvqBjRuyGVTJ6RpavpoAP6NBUfGSRXHuBs7cHRDpDSuErYeDiEo4XHTonRp68g==} + eslint-plugin-storybook@10.6.0: + resolution: {integrity: sha512-HK3yD5Nslv97buJ5JfT1xFkTOoyJgGIENmBoZZI3F8t2UpBzb/dHpKyCh5bQ8AvJWnhoM/WAtDNqyd9nq6xgcg==} peerDependencies: eslint: '>=8' - storybook: ^10.5.5 eslint-scope@7.2.2: resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} @@ -4005,9 +3993,9 @@ packages: evp_bytestokey@1.0.3: resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} - execa@9.6.1: - resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} - engines: {node: ^18.19.0 || >=20.5.0} + execa@10.0.1: + resolution: {integrity: sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw==} + engines: {node: '>=22'} expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} @@ -4270,8 +4258,8 @@ packages: i18next-browser-languagedetector@8.2.1: resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} - i18next-cli@1.67.3: - resolution: {integrity: sha512-kW5Hq0kzOGoZQCxOLqtrcj4NqIGmQJkBr5c78XQz3WqBgZuJ1PzvLEEjoXdyDkX1IwPO19OTEv3sOeCCWU+Igg==} + i18next-cli@1.72.4: + resolution: {integrity: sha512-YnyjYSasqZbZCLYQOvk9pvSYZy1ANjq7yG4cJ5Rd2WF99sALUKCCIzvfPXu9OfBgSy4CosSnjg8GZI1c0CrrNA==} engines: {node: '>=22'} hasBin: true @@ -4279,8 +4267,8 @@ packages: resolution: {integrity: sha512-n5UexwEVt0OoIAhG2MWpSnAVJW1U8mQrQTmXyxc5DMAx+NLhcLZhSMJo/FnUsA5JQ3obTYqTgB7YIuZKWpDgow==} hasBin: true - i18next@26.3.6: - resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} + i18next@26.4.1: + resolution: {integrity: sha512-9YbX5E6gd1H+yaOSX3izCsPj5iWXyH7X4oC+iuHHJJw8AeHglzOK5SJf+1CHxaYKYigcea+8jvzSxqYx46YvyA==} peerDependencies: typescript: ^5 || ^6 || ^7 peerDependenciesMeta: @@ -4320,8 +4308,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - inquirer@14.0.2: - resolution: {integrity: sha512-VsSx1JneSNp3ld1veMTLe+UDcUD8Tw2/jjOthhkX3/IX2q+xHhVELifeb/hsb1fBw31pabEPNUf/xUOyb+KZjA==} + inquirer@14.2.1: + resolution: {integrity: sha512-jLO8986l0/jW+K2Y5BHAhrD6ztnm8MHQgnTI5BwKV6can/LN6xYBcLbtUSvDt1jXNTASwCZszZk0ZBUT5YmH6Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -4675,6 +4663,9 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} + magic-string@1.2.3: + resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + magicast@0.5.4: resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} @@ -4756,8 +4747,8 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} - nanoid@3.3.17: - resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4862,11 +4853,11 @@ packages: oxc-resolver@11.19.1: resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} - oxc-resolver@11.24.2: - resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + oxc-resolver@11.21.2: + resolution: {integrity: sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==} - oxfmt@0.61.0: - resolution: {integrity: sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ==} + oxfmt@0.66.0: + resolution: {integrity: sha512-FfvqR8RFtV6JJpRrpkfqyVCQ7HDvZ/VriWFx7veftCgL1B5ZO9qNr+1rvPieycMQnNfVG0PWyJQiy7p0hq1I5w==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -4882,8 +4873,8 @@ packages: resolution: {integrity: sha512-7DBpqyLZCfyoXiivyfzt9Xmju/K1RcN+Y1W7buEwrgRCWWF11v9alypPqWGZBmh2erDkKL/kVyhKUH2Px+t13A==} hasBin: true - oxlint@1.76.0: - resolution: {integrity: sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==} + oxlint@1.81.0: + resolution: {integrity: sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -5008,17 +4999,21 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pkg-dir@5.0.0: resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==} engines: {node: '>=10'} - playwright-core@1.62.0: - resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} engines: {node: '>=20'} hasBin: true - playwright@1.62.0: - resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} engines: {node: '>=20'} hasBin: true @@ -5194,8 +5189,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} posthog-js@1.408.2: @@ -5222,8 +5217,8 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - pretty-ms@9.3.0: - resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + pretty-ms@9.3.1: + resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} process-nextick-args@2.0.1: @@ -5305,8 +5300,8 @@ packages: typescript: optional: true - react-i18next@17.0.11: - resolution: {integrity: sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==} + react-i18next@17.0.13: + resolution: {integrity: sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==} peerDependencies: i18next: '>= 26.2.0' react: '>= 16.8.0' @@ -5347,15 +5342,15 @@ packages: '@types/react': optional: true - react-router-dom@7.18.2: - resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==} + react-router-dom@7.18.3: + resolution: {integrity: sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' react-dom: '>=18' - react-router@7.18.2: - resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==} + react-router@7.18.3: + resolution: {integrity: sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -5398,12 +5393,12 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} - recast@0.23.19: - resolution: {integrity: sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==} + recast@0.23.21: + resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} redent@3.0.0: @@ -5451,8 +5446,8 @@ packages: resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} engines: {node: '>= 0.8'} - rolldown@1.1.5: - resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -5491,8 +5486,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sass@1.102.0: - resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==} + sass@1.103.1: + resolution: {integrity: sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==} engines: {node: '>=20.19.0'} hasBin: true @@ -5609,8 +5604,8 @@ packages: resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} engines: {node: '>=18'} - storybook@10.5.5: - resolution: {integrity: sha512-UscBIBJDloUeqntukHOhP1a5W/vouePDJbzPSxj466WK801FZtzQiMffMtkjzJiWSuj20wfaYlB2QQKh9aOYAg==} + storybook@10.6.0: + resolution: {integrity: sha512-H48X6AURAFTpfN2zEux6vNesee5Oq8Gxj1IanYmblPZ218sfKSeyyerF1NC7GD7BrruoC8L4/cU0zII4y+d6zw==} hasBin: true peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5715,8 +5710,8 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.3.0: - resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -5735,8 +5730,8 @@ packages: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} - tinyspy@4.0.4: - resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} engines: {node: '>=14.0.0'} tldts-core@6.1.86: @@ -5906,8 +5901,8 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} - uuid@14.0.1: - resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} hasBin: true vaul@1.1.2: @@ -5945,13 +5940,13 @@ packages: peerDependencies: vite: ^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8 - vite@8.1.5: - resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 esbuild: ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -5993,20 +5988,20 @@ packages: peerDependencies: vitest: '>=1' - vitest@4.1.10: - resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.10 - '@vitest/browser-preview': 4.1.10 - '@vitest/browser-webdriverio': 4.1.10 - '@vitest/coverage-istanbul': 4.1.10 - '@vitest/coverage-v8': 4.1.10 - '@vitest/ui': 4.1.10 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -6089,6 +6084,11 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-command@0.1.0: + resolution: {integrity: sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA==} + engines: {node: '>=22'} + hasBin: true + which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} @@ -6149,6 +6149,18 @@ packages: utf-8-validate: optional: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -6369,11 +6381,11 @@ snapshots: unplugin: 1.16.1 zod: 3.25.76 - '@codecov/vite-plugin@1.9.1(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': + '@codecov/vite-plugin@1.9.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))': dependencies: '@codecov/bundler-plugin-core': 1.9.1 unplugin: 1.16.1 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) '@croct/json5-parser@0.2.2': dependencies: @@ -6411,272 +6423,272 @@ snapshots: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-alpha-function@1.0.1(postcss@8.5.25)': + '@csstools/postcss-alpha-function@1.0.1(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.25)': + '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)': dependencies: '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 - '@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.25)': + '@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-color-function@4.0.12(postcss@8.5.25)': + '@csstools/postcss-color-function@4.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-color-mix-function@3.0.12(postcss@8.5.25)': + '@csstools/postcss-color-mix-function@3.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-color-mix-variadic-function-arguments@1.0.2(postcss@8.5.25)': + '@csstools/postcss-color-mix-variadic-function-arguments@1.0.2(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-content-alt-text@2.0.8(postcss@8.5.25)': + '@csstools/postcss-content-alt-text@2.0.8(postcss@8.5.26)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-contrast-color-function@2.0.12(postcss@8.5.25)': + '@csstools/postcss-contrast-color-function@2.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-exponential-functions@2.0.9(postcss@8.5.25)': + '@csstools/postcss-exponential-functions@2.0.9(postcss@8.5.26)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-font-format-keywords@4.0.0(postcss@8.5.25)': + '@csstools/postcss-font-format-keywords@4.0.0(postcss@8.5.26)': dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-gamut-mapping@2.0.11(postcss@8.5.25)': + '@csstools/postcss-gamut-mapping@2.0.11(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-gradients-interpolation-method@5.0.12(postcss@8.5.25)': + '@csstools/postcss-gradients-interpolation-method@5.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-hwb-function@4.0.12(postcss@8.5.25)': + '@csstools/postcss-hwb-function@4.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-ic-unit@4.0.4(postcss@8.5.25)': + '@csstools/postcss-ic-unit@4.0.4(postcss@8.5.26)': dependencies: - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-initial@2.0.1(postcss@8.5.25)': + '@csstools/postcss-initial@2.0.1(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.25)': + '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)': dependencies: '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 - '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.25)': + '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-logical-float-and-clear@3.0.0(postcss@8.5.25)': + '@csstools/postcss-logical-float-and-clear@3.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-logical-overflow@2.0.0(postcss@8.5.25)': + '@csstools/postcss-logical-overflow@2.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-logical-overscroll-behavior@2.0.0(postcss@8.5.25)': + '@csstools/postcss-logical-overscroll-behavior@2.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-logical-resize@3.0.0(postcss@8.5.25)': + '@csstools/postcss-logical-resize@3.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-logical-viewport-units@3.0.4(postcss@8.5.25)': + '@csstools/postcss-logical-viewport-units@3.0.4(postcss@8.5.26)': dependencies: '@csstools/css-tokenizer': 3.0.4 - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-media-minmax@2.0.9(postcss@8.5.25)': + '@csstools/postcss-media-minmax@2.0.9(postcss@8.5.26)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.5(postcss@8.5.25)': + '@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.5(postcss@8.5.26)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-nested-calc@4.0.0(postcss@8.5.25)': + '@csstools/postcss-nested-calc@4.0.0(postcss@8.5.26)': dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-normalize-display-values@4.0.1(postcss@8.5.25)': + '@csstools/postcss-normalize-display-values@4.0.1(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-oklab-function@4.0.12(postcss@8.5.25)': + '@csstools/postcss-oklab-function@4.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-position-area-property@1.0.0(postcss@8.5.25)': + '@csstools/postcss-position-area-property@1.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-progressive-custom-properties@4.2.1(postcss@8.5.25)': + '@csstools/postcss-progressive-custom-properties@4.2.1(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-property-rule-prelude-list@1.0.0(postcss@8.5.25)': + '@csstools/postcss-property-rule-prelude-list@1.0.0(postcss@8.5.26)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-random-function@2.0.1(postcss@8.5.25)': + '@csstools/postcss-random-function@2.0.1(postcss@8.5.26)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-relative-color-syntax@3.0.12(postcss@8.5.25)': + '@csstools/postcss-relative-color-syntax@3.0.12(postcss@8.5.26)': dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.25)': + '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 - '@csstools/postcss-sign-functions@1.1.4(postcss@8.5.25)': + '@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-stepped-value-functions@4.0.9(postcss@8.5.25)': + '@csstools/postcss-stepped-value-functions@4.0.9(postcss@8.5.26)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-syntax-descriptor-syntax-production@1.0.1(postcss@8.5.25)': + '@csstools/postcss-syntax-descriptor-syntax-production@1.0.1(postcss@8.5.26)': dependencies: '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-system-ui-font-family@1.0.0(postcss@8.5.25)': + '@csstools/postcss-system-ui-font-family@1.0.0(postcss@8.5.26)': dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-text-decoration-shorthand@4.0.3(postcss@8.5.25)': + '@csstools/postcss-text-decoration-shorthand@4.0.3(postcss@8.5.26)': dependencies: '@csstools/color-helpers': 5.1.0 - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - '@csstools/postcss-trigonometric-functions@4.0.9(postcss@8.5.25)': + '@csstools/postcss-trigonometric-functions@4.0.9(postcss@8.5.26)': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 - '@csstools/postcss-unset-value@4.0.0(postcss@8.5.25)': + '@csstools/postcss-unset-value@4.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.1)': dependencies: @@ -6686,11 +6698,11 @@ snapshots: dependencies: postcss-selector-parser: 7.1.1 - '@csstools/utilities@2.0.0(postcss@8.5.25)': + '@csstools/utilities@2.0.0(postcss@8.5.26)': dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - '@emnapi/core@1.11.1': + '@emnapi/core@1.11.0': dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 @@ -6708,7 +6720,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.1': + '@emnapi/runtime@1.11.0': dependencies: tslib: 2.8.1 optional: true @@ -6733,82 +6745,82 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true '@eslint-community/eslint-utils@4.10.1(eslint@8.57.1(supports-color@7.2.0))': @@ -6914,29 +6926,29 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@inquirer/ansi@2.0.7': {} + '@inquirer/ansi@2.0.8': {} - '@inquirer/checkbox@5.2.1(@types/node@24.13.3)': + '@inquirer/checkbox@5.2.4(@types/node@24.13.3)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@24.13.3) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@24.13.3) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.2(@types/node@24.13.3) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@24.13.3) optionalDependencies: '@types/node': 24.13.3 - '@inquirer/confirm@6.1.1(@types/node@24.13.3)': + '@inquirer/confirm@6.3.1(@types/node@24.13.3)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.3) - '@inquirer/type': 4.0.7(@types/node@24.13.3) + '@inquirer/core': 12.0.2(@types/node@24.13.3) + '@inquirer/type': 4.1.1(@types/node@24.13.3) optionalDependencies: '@types/node': 24.13.3 - '@inquirer/core@11.2.1(@types/node@24.13.3)': + '@inquirer/core@12.0.2(@types/node@24.13.3)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@24.13.3) + '@inquirer/ansi': 2.0.8 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@24.13.3) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 @@ -6944,92 +6956,92 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 - '@inquirer/editor@5.2.2(@types/node@24.13.3)': + '@inquirer/editor@5.3.2(@types/node@24.13.3)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.3) - '@inquirer/external-editor': 3.0.3(@types/node@24.13.3) - '@inquirer/type': 4.0.7(@types/node@24.13.3) + '@inquirer/core': 12.0.2(@types/node@24.13.3) + '@inquirer/external-editor': 3.0.5(@types/node@24.13.3) + '@inquirer/type': 4.1.1(@types/node@24.13.3) optionalDependencies: '@types/node': 24.13.3 - '@inquirer/expand@5.1.1(@types/node@24.13.3)': + '@inquirer/expand@5.1.4(@types/node@24.13.3)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.3) - '@inquirer/type': 4.0.7(@types/node@24.13.3) + '@inquirer/core': 12.0.2(@types/node@24.13.3) + '@inquirer/type': 4.1.1(@types/node@24.13.3) optionalDependencies: '@types/node': 24.13.3 - '@inquirer/external-editor@3.0.3(@types/node@24.13.3)': + '@inquirer/external-editor@3.0.5(@types/node@24.13.3)': dependencies: chardet: 2.2.0 iconv-lite: 0.7.3 optionalDependencies: '@types/node': 24.13.3 - '@inquirer/figures@2.0.7': {} + '@inquirer/figures@2.0.9': {} - '@inquirer/input@5.1.2(@types/node@24.13.3)': + '@inquirer/input@5.1.5(@types/node@24.13.3)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.3) - '@inquirer/type': 4.0.7(@types/node@24.13.3) + '@inquirer/core': 12.0.2(@types/node@24.13.3) + '@inquirer/type': 4.1.1(@types/node@24.13.3) optionalDependencies: '@types/node': 24.13.3 - '@inquirer/number@4.1.1(@types/node@24.13.3)': + '@inquirer/number@4.2.2(@types/node@24.13.3)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.3) - '@inquirer/type': 4.0.7(@types/node@24.13.3) + '@inquirer/core': 12.0.2(@types/node@24.13.3) + '@inquirer/type': 4.1.1(@types/node@24.13.3) optionalDependencies: '@types/node': 24.13.3 - '@inquirer/password@5.1.1(@types/node@24.13.3)': + '@inquirer/password@5.2.1(@types/node@24.13.3)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@24.13.3) - '@inquirer/type': 4.0.7(@types/node@24.13.3) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.2(@types/node@24.13.3) + '@inquirer/type': 4.1.1(@types/node@24.13.3) optionalDependencies: '@types/node': 24.13.3 - '@inquirer/prompts@8.5.2(@types/node@24.13.3)': + '@inquirer/prompts@8.7.1(@types/node@24.13.3)': dependencies: - '@inquirer/checkbox': 5.2.1(@types/node@24.13.3) - '@inquirer/confirm': 6.1.1(@types/node@24.13.3) - '@inquirer/editor': 5.2.2(@types/node@24.13.3) - '@inquirer/expand': 5.1.1(@types/node@24.13.3) - '@inquirer/input': 5.1.2(@types/node@24.13.3) - '@inquirer/number': 4.1.1(@types/node@24.13.3) - '@inquirer/password': 5.1.1(@types/node@24.13.3) - '@inquirer/rawlist': 5.3.1(@types/node@24.13.3) - '@inquirer/search': 4.2.1(@types/node@24.13.3) - '@inquirer/select': 5.2.1(@types/node@24.13.3) + '@inquirer/checkbox': 5.2.4(@types/node@24.13.3) + '@inquirer/confirm': 6.3.1(@types/node@24.13.3) + '@inquirer/editor': 5.3.2(@types/node@24.13.3) + '@inquirer/expand': 5.1.4(@types/node@24.13.3) + '@inquirer/input': 5.1.5(@types/node@24.13.3) + '@inquirer/number': 4.2.2(@types/node@24.13.3) + '@inquirer/password': 5.2.1(@types/node@24.13.3) + '@inquirer/rawlist': 5.3.4(@types/node@24.13.3) + '@inquirer/search': 4.3.2(@types/node@24.13.3) + '@inquirer/select': 5.2.4(@types/node@24.13.3) optionalDependencies: '@types/node': 24.13.3 - '@inquirer/rawlist@5.3.1(@types/node@24.13.3)': + '@inquirer/rawlist@5.3.4(@types/node@24.13.3)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.3) - '@inquirer/type': 4.0.7(@types/node@24.13.3) + '@inquirer/core': 12.0.2(@types/node@24.13.3) + '@inquirer/type': 4.1.1(@types/node@24.13.3) optionalDependencies: '@types/node': 24.13.3 - '@inquirer/search@4.2.1(@types/node@24.13.3)': + '@inquirer/search@4.3.2(@types/node@24.13.3)': dependencies: - '@inquirer/core': 11.2.1(@types/node@24.13.3) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@24.13.3) + '@inquirer/core': 12.0.2(@types/node@24.13.3) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@24.13.3) optionalDependencies: '@types/node': 24.13.3 - '@inquirer/select@5.2.1(@types/node@24.13.3)': + '@inquirer/select@5.2.4(@types/node@24.13.3)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@24.13.3) - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@24.13.3) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.2(@types/node@24.13.3) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@24.13.3) optionalDependencies: '@types/node': 24.13.3 - '@inquirer/type@4.0.7(@types/node@24.13.3)': + '@inquirer/type@4.1.1(@types/node@24.13.3)': optionalDependencies: '@types/node': 24.13.3 @@ -7042,11 +7054,11 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))': dependencies: glob: 10.5.0 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) optionalDependencies: typescript: 5.9.3 @@ -7069,6 +7081,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -7108,10 +7122,10 @@ snapshots: '@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {} - '@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8)': + '@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)': dependencies: '@types/mdx': 2.0.14 - '@types/react': 19.2.17 + '@types/react': 19.2.18 react: 19.2.8 '@mediapipe/tasks-vision@0.10.35': {} @@ -7123,21 +7137,14 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true - '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0)': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': - dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 - '@tybys/wasm-util': 0.10.3 - optional: true - - '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 @@ -7269,7 +7276,7 @@ snapshots: dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 - '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) optional: true '@oxc-parser/binding-win32-arm64-msvc@0.127.0': @@ -7283,102 +7290,102 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@oxc-project/types@0.139.0': {} + '@oxc-project/types@0.148.0': {} '@oxc-resolver/binding-android-arm-eabi@11.19.1': optional: true - '@oxc-resolver/binding-android-arm-eabi@11.24.2': + '@oxc-resolver/binding-android-arm-eabi@11.21.2': optional: true '@oxc-resolver/binding-android-arm64@11.19.1': optional: true - '@oxc-resolver/binding-android-arm64@11.24.2': + '@oxc-resolver/binding-android-arm64@11.21.2': optional: true '@oxc-resolver/binding-darwin-arm64@11.19.1': optional: true - '@oxc-resolver/binding-darwin-arm64@11.24.2': + '@oxc-resolver/binding-darwin-arm64@11.21.2': optional: true '@oxc-resolver/binding-darwin-x64@11.19.1': optional: true - '@oxc-resolver/binding-darwin-x64@11.24.2': + '@oxc-resolver/binding-darwin-x64@11.21.2': optional: true '@oxc-resolver/binding-freebsd-x64@11.19.1': optional: true - '@oxc-resolver/binding-freebsd-x64@11.24.2': + '@oxc-resolver/binding-freebsd-x64@11.21.2': optional: true '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': optional: true - '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + '@oxc-resolver/binding-linux-arm-gnueabihf@11.21.2': optional: true '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': optional: true - '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + '@oxc-resolver/binding-linux-arm-musleabihf@11.21.2': optional: true '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': optional: true - '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + '@oxc-resolver/binding-linux-arm64-gnu@11.21.2': optional: true '@oxc-resolver/binding-linux-arm64-musl@11.19.1': optional: true - '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + '@oxc-resolver/binding-linux-arm64-musl@11.21.2': optional: true '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': optional: true - '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + '@oxc-resolver/binding-linux-ppc64-gnu@11.21.2': optional: true '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': optional: true - '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + '@oxc-resolver/binding-linux-riscv64-gnu@11.21.2': optional: true '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': optional: true - '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + '@oxc-resolver/binding-linux-riscv64-musl@11.21.2': optional: true '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': optional: true - '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + '@oxc-resolver/binding-linux-s390x-gnu@11.21.2': optional: true '@oxc-resolver/binding-linux-x64-gnu@11.19.1': optional: true - '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + '@oxc-resolver/binding-linux-x64-gnu@11.21.2': optional: true '@oxc-resolver/binding-linux-x64-musl@11.19.1': optional: true - '@oxc-resolver/binding-linux-x64-musl@11.24.2': + '@oxc-resolver/binding-linux-x64-musl@11.21.2': optional: true '@oxc-resolver/binding-openharmony-arm64@11.19.1': optional: true - '@oxc-resolver/binding-openharmony-arm64@11.24.2': + '@oxc-resolver/binding-openharmony-arm64@11.21.2': optional: true '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': @@ -7389,17 +7396,17 @@ snapshots: - '@emnapi/runtime' optional: true - '@oxc-resolver/binding-wasm32-wasi@11.24.2': + '@oxc-resolver/binding-wasm32-wasi@11.21.2': dependencies: - '@emnapi/core': 1.11.2 - '@emnapi/runtime': 1.11.2 - '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@emnapi/core': 1.11.0 + '@emnapi/runtime': 1.11.0 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.0)(@emnapi/runtime@1.11.0) optional: true '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': optional: true - '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + '@oxc-resolver/binding-win32-arm64-msvc@11.21.2': optional: true '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': @@ -7408,64 +7415,64 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.19.1': optional: true - '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + '@oxc-resolver/binding-win32-x64-msvc@11.21.2': optional: true - '@oxfmt/binding-android-arm-eabi@0.61.0': + '@oxfmt/binding-android-arm-eabi@0.66.0': optional: true - '@oxfmt/binding-android-arm64@0.61.0': + '@oxfmt/binding-android-arm64@0.66.0': optional: true - '@oxfmt/binding-darwin-arm64@0.61.0': + '@oxfmt/binding-darwin-arm64@0.66.0': optional: true - '@oxfmt/binding-darwin-x64@0.61.0': + '@oxfmt/binding-darwin-x64@0.66.0': optional: true - '@oxfmt/binding-freebsd-x64@0.61.0': + '@oxfmt/binding-freebsd-x64@0.66.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.61.0': + '@oxfmt/binding-linux-arm-gnueabihf@0.66.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.61.0': + '@oxfmt/binding-linux-arm-musleabihf@0.66.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.61.0': + '@oxfmt/binding-linux-arm64-gnu@0.66.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.61.0': + '@oxfmt/binding-linux-arm64-musl@0.66.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.61.0': + '@oxfmt/binding-linux-ppc64-gnu@0.66.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.61.0': + '@oxfmt/binding-linux-riscv64-gnu@0.66.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.61.0': + '@oxfmt/binding-linux-riscv64-musl@0.66.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.61.0': + '@oxfmt/binding-linux-s390x-gnu@0.66.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.61.0': + '@oxfmt/binding-linux-x64-gnu@0.66.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.61.0': + '@oxfmt/binding-linux-x64-musl@0.66.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.61.0': + '@oxfmt/binding-openharmony-arm64@0.66.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.61.0': + '@oxfmt/binding-win32-arm64-msvc@0.66.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.61.0': + '@oxfmt/binding-win32-ia32-msvc@0.66.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.61.0': + '@oxfmt/binding-win32-x64-msvc@0.66.0': optional: true '@oxlint-tsgolint/darwin-arm64@0.25.0': @@ -7486,61 +7493,61 @@ snapshots: '@oxlint-tsgolint/win32-x64@0.25.0': optional: true - '@oxlint/binding-android-arm-eabi@1.76.0': + '@oxlint/binding-android-arm-eabi@1.81.0': optional: true - '@oxlint/binding-android-arm64@1.76.0': + '@oxlint/binding-android-arm64@1.81.0': optional: true - '@oxlint/binding-darwin-arm64@1.76.0': + '@oxlint/binding-darwin-arm64@1.81.0': optional: true - '@oxlint/binding-darwin-x64@1.76.0': + '@oxlint/binding-darwin-x64@1.81.0': optional: true - '@oxlint/binding-freebsd-x64@1.76.0': + '@oxlint/binding-freebsd-x64@1.81.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.76.0': + '@oxlint/binding-linux-arm-gnueabihf@1.81.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.76.0': + '@oxlint/binding-linux-arm-musleabihf@1.81.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.76.0': + '@oxlint/binding-linux-arm64-gnu@1.81.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.76.0': + '@oxlint/binding-linux-arm64-musl@1.81.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.76.0': + '@oxlint/binding-linux-ppc64-gnu@1.81.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.76.0': + '@oxlint/binding-linux-riscv64-gnu@1.81.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.76.0': + '@oxlint/binding-linux-riscv64-musl@1.81.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.76.0': + '@oxlint/binding-linux-s390x-gnu@1.81.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.76.0': + '@oxlint/binding-linux-x64-gnu@1.81.0': optional: true - '@oxlint/binding-linux-x64-musl@1.76.0': + '@oxlint/binding-linux-x64-musl@1.81.0': optional: true - '@oxlint/binding-openharmony-arm64@1.76.0': + '@oxlint/binding-openharmony-arm64@1.81.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.76.0': + '@oxlint/binding-win32-arm64-msvc@1.81.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.76.0': + '@oxlint/binding-win32-ia32-msvc@1.81.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.76.0': + '@oxlint/binding-win32-x64-msvc@1.81.0': optional: true '@parcel/watcher-android-arm64@2.6.0': @@ -7584,7 +7591,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.5 + picomatch: 4.0.7 optionalDependencies: '@parcel/watcher-android-arm64': 2.6.0 '@parcel/watcher-darwin-arm64': 2.6.0 @@ -7603,9 +7610,9 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@playwright/test@1.62.0': + '@playwright/test@1.62.1': dependencies: - playwright: 1.62.0 + playwright: 1.62.1 '@polka/url@1.0.0-next.29': {} @@ -7624,354 +7631,354 @@ snapshots: '@radix-ui/primitive@1.1.7': {} - '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-context-menu@2.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-context-menu@2.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-context@1.2.2(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) aria-hidden: 1.2.6 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-direction@1.1.4(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-direction@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-form@0.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-form@0.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-label': 2.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-id@1.1.4(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-label@2.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-label@2.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) aria-hidden: 1.2.6 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) '@radix-ui/rect': 1.1.3 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-progress@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-progress@1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-separator@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-separator@1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/number': 1.1.3 '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@radix-ui/react-slot@1.3.3(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-previous@1.1.4(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-previous@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: '@radix-ui/rect': 1.1.3 react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-size@1.1.4(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) '@radix-ui/rect@1.1.3': {} @@ -8008,63 +8015,59 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - '@rolldown/binding-android-arm64@1.1.5': + '@rolldown/binding-android-arm-eabi@1.2.7': optional: true - '@rolldown/binding-darwin-arm64@1.1.5': + '@rolldown/binding-android-arm64@1.2.7': optional: true - '@rolldown/binding-darwin-x64@1.1.5': + '@rolldown/binding-darwin-arm64@1.2.7': optional: true - '@rolldown/binding-freebsd-x64@1.1.5': + '@rolldown/binding-darwin-x64@1.2.7': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + '@rolldown/binding-freebsd-x64@1.2.7': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.5': + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.5': + '@rolldown/binding-linux-arm64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.5': + '@rolldown/binding-linux-arm64-musl@1.2.7': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.5': + '@rolldown/binding-linux-ppc64-gnu@1.2.7': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.5': + '@rolldown/binding-linux-s390x-gnu@1.2.7': optional: true - '@rolldown/binding-linux-x64-musl@1.1.5': + '@rolldown/binding-linux-x64-gnu@1.2.7': optional: true - '@rolldown/binding-openharmony-arm64@1.1.5': + '@rolldown/binding-linux-x64-musl@1.2.7': optional: true - '@rolldown/binding-wasm32-wasi@1.1.5': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-openharmony-arm64@1.2.7': optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.5': + '@rolldown/binding-win32-arm64-msvc@1.2.7': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.5': + '@rolldown/binding-win32-x64-msvc@1.2.7': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) picomatch: 4.0.4 - rolldown: 1.1.5 + rolldown: 1.2.7 optionalDependencies: '@babel/runtime': 8.0.0 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) '@rolldown/pluginutils@1.0.1': {} @@ -8093,7 +8096,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 - picomatch: 4.0.5 + picomatch: 4.0.7 optionalDependencies: rollup: 4.60.1 @@ -8281,57 +8284,39 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.5.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.60.1)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': + '@storybook/addon-docs@10.6.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8))': dependencies: - '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.8) - '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.60.1)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + '@mdx-js/react': 3.1.1(@types/react@19.2.18)(react@19.2.8) '@storybook/icons': 2.1.0(react@19.2.8) - '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8)) + '@storybook/react-dom-shim': 10.6.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8)) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8) + storybook: 10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8) ts-dedent: 2.3.0 + unplugin: 2.3.11 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 transitivePeerDependencies: - '@types/react-dom' - - esbuild - - rollup - - vite - - webpack - '@storybook/addon-vitest@10.5.5(@vitest/browser-playwright@4.1.10)(@vitest/browser@4.1.10)(@vitest/runner@4.1.10)(react@19.2.8)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(vitest@4.1.10)': + '@storybook/addon-vitest@10.6.0(@vitest/browser-playwright@4.1.11)(@vitest/browser@4.1.11)(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8))(vitest@4.1.11)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.1.0(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8) + storybook: 10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8) optionalDependencies: - '@vitest/browser': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10) - '@vitest/browser-playwright': 4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10) - '@vitest/runner': 4.1.10 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/runner': 4.1.11 + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) transitivePeerDependencies: - react - '@storybook/builder-vite@10.5.5(esbuild@0.28.1)(rollup@4.60.1)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': + '@storybook/builder-vite@10.6.0(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))': dependencies: - '@storybook/csf-plugin': 10.5.5(esbuild@0.28.1)(rollup@4.60.1)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) - storybook: 10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8) + storybook: 10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8) ts-dedent: 2.3.0 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) - transitivePeerDependencies: - - esbuild - - rollup - - webpack - - '@storybook/csf-plugin@10.5.5(esbuild@0.28.1)(rollup@4.60.1)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': - dependencies: - storybook: 10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8) - unplugin: 2.3.11 - optionalDependencies: - esbuild: 0.28.1 - rollup: 4.60.1 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) '@storybook/global@5.0.0': {} @@ -8339,52 +8324,50 @@ snapshots: dependencies: react: 19.2.8 - '@storybook/react-dom-shim@10.5.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))': + '@storybook/react-dom-shim@10.6.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8))': dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8) + storybook: 10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@storybook/react-vite@10.5.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.60.1)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': + '@storybook/react-vite@10.6.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(rollup@4.60.1)(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) '@rollup/pluginutils': 5.4.0(rollup@4.60.1) - '@storybook/builder-vite': 10.5.5(esbuild@0.28.1)(rollup@4.60.1)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) - '@storybook/react': 10.5.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3) + '@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) + '@storybook/react': 10.6.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3) empathic: 2.0.1 - magic-string: 0.30.21 + magic-string: 1.2.3 react: 19.2.8 react-docgen: 8.0.3(supports-color@7.2.0) react-dom: 19.2.8(react@19.2.8) resolve: 1.22.12 - storybook: 10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8) + storybook: 10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8) tsconfig-paths: 4.2.0 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - '@types/react' - '@types/react-dom' - - esbuild - rollup - supports-color - - webpack - '@storybook/react@10.5.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)': + '@storybook/react@10.6.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8)) + '@storybook/react-dom-shim': 10.6.0(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8)) react: 19.2.8 react-docgen: 8.0.3(supports-color@7.2.0) react-docgen-typescript: 2.4.0(typescript@5.9.3) react-dom: 19.2.8(react@19.2.8) - storybook: 10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8) + storybook: 10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -8459,59 +8442,59 @@ snapshots: transitivePeerDependencies: - supports-color - '@swc/core-darwin-arm64@1.15.47': + '@swc/core-darwin-arm64@1.16.2': optional: true - '@swc/core-darwin-x64@1.15.47': + '@swc/core-darwin-x64@1.16.2': optional: true - '@swc/core-linux-arm-gnueabihf@1.15.47': + '@swc/core-linux-arm-gnueabihf@1.16.2': optional: true - '@swc/core-linux-arm64-gnu@1.15.47': + '@swc/core-linux-arm64-gnu@1.16.2': optional: true - '@swc/core-linux-arm64-musl@1.15.47': + '@swc/core-linux-arm64-musl@1.16.2': optional: true - '@swc/core-linux-ppc64-gnu@1.15.47': + '@swc/core-linux-ppc64-gnu@1.16.2': optional: true - '@swc/core-linux-s390x-gnu@1.15.47': + '@swc/core-linux-s390x-gnu@1.16.2': optional: true - '@swc/core-linux-x64-gnu@1.15.47': + '@swc/core-linux-x64-gnu@1.16.2': optional: true - '@swc/core-linux-x64-musl@1.15.47': + '@swc/core-linux-x64-musl@1.16.2': optional: true - '@swc/core-win32-arm64-msvc@1.15.47': + '@swc/core-win32-arm64-msvc@1.16.2': optional: true - '@swc/core-win32-ia32-msvc@1.15.47': + '@swc/core-win32-ia32-msvc@1.16.2': optional: true - '@swc/core-win32-x64-msvc@1.15.47': + '@swc/core-win32-x64-msvc@1.16.2': optional: true - '@swc/core@1.15.47': + '@swc/core@1.16.2': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.28 optionalDependencies: - '@swc/core-darwin-arm64': 1.15.47 - '@swc/core-darwin-x64': 1.15.47 - '@swc/core-linux-arm-gnueabihf': 1.15.47 - '@swc/core-linux-arm64-gnu': 1.15.47 - '@swc/core-linux-arm64-musl': 1.15.47 - '@swc/core-linux-ppc64-gnu': 1.15.47 - '@swc/core-linux-s390x-gnu': 1.15.47 - '@swc/core-linux-x64-gnu': 1.15.47 - '@swc/core-linux-x64-musl': 1.15.47 - '@swc/core-win32-arm64-msvc': 1.15.47 - '@swc/core-win32-ia32-msvc': 1.15.47 - '@swc/core-win32-x64-msvc': 1.15.47 + '@swc/core-darwin-arm64': 1.16.2 + '@swc/core-darwin-x64': 1.16.2 + '@swc/core-linux-arm-gnueabihf': 1.16.2 + '@swc/core-linux-arm64-gnu': 1.16.2 + '@swc/core-linux-arm64-musl': 1.16.2 + '@swc/core-linux-ppc64-gnu': 1.16.2 + '@swc/core-linux-s390x-gnu': 1.16.2 + '@swc/core-linux-x64-gnu': 1.16.2 + '@swc/core-linux-x64-musl': 1.16.2 + '@swc/core-win32-arm64-msvc': 1.16.2 + '@swc/core-win32-ia32-msvc': 1.16.2 + '@swc/core-win32-x64-msvc': 1.16.2 '@swc/counter@0.1.3': {} @@ -8539,17 +8522,17 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) - '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)': dependencies: '@testing-library/dom': 10.4.1 @@ -8644,11 +8627,11 @@ snapshots: dependencies: '@types/node': 24.13.3 - '@types/react-dom@19.2.3(@types/react@19.2.17)': + '@types/react-dom@19.2.5(@types/react@19.2.18)': dependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@types/react@19.2.17': + '@types/react@19.2.18': dependencies: csstype: 3.2.3 @@ -8661,32 +8644,32 @@ snapshots: '@types/trusted-types@2.0.7': optional: true - '@typescript-eslint/project-service@8.65.0(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/project-service@8.69.0(supports-color@7.2.0)(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@5.9.3) + '@typescript-eslint/types': 8.69.0 debug: 4.4.3(supports-color@7.2.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.65.0': + '@typescript-eslint/scope-manager@8.69.0': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.69.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/types@8.65.0': {} + '@typescript-eslint/types@8.69.0': {} - '@typescript-eslint/typescript-estree@8.65.0(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.69.0(supports-color@7.2.0)(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/project-service': 8.69.0(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@5.9.3) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 @@ -8696,20 +8679,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/utils@8.69.0(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1(supports-color@7.2.0)) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(supports-color@7.2.0)(typescript@5.9.3) eslint: 8.57.1(supports-color@7.2.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.65.0': + '@typescript-eslint/visitor-keys@8.69.0': dependencies: - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.3': {} @@ -8721,64 +8704,64 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.8 - '@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.17)(react@19.2.8)': + '@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8)': optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 react: 19.2.8 - '@vector-im/compound-web@10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.17)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@vector-im/compound-web@10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fontsource/inconsolata': 5.3.0 '@fontsource/inter': 5.3.0 - '@radix-ui/react-context-menu': 2.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-dropdown-menu': 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-form': 0.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) - '@vector-im/compound-design-tokens': 10.2.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context-menu': 2.3.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dropdown-menu': 2.1.24(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-form': 0.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) + '@vector-im/compound-design-tokens': 10.2.4(@types/react@19.2.18)(react@19.2.8) classnames: 2.5.1 react: 19.2.8 - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + vaul: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 transitivePeerDependencies: - '@types/react-dom' - react-dom - '@vitejs/plugin-react@6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': + '@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) optionalDependencies: - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) babel-plugin-react-compiler: 1.0.0 - '@vitest/browser-playwright@4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10)': + '@vitest/browser-playwright@4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11)': dependencies: - '@vitest/browser': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10) - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) - playwright: 1.62.0 + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) + playwright: 1.62.1 tinyrainbow: 3.1.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10)': + '@vitest/browser@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) - '@vitest/utils': 4.1.10 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) ws: 8.21.1 transitivePeerDependencies: - bufferutil @@ -8786,10 +8769,10 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10)': + '@vitest/coverage-v8@4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 ast-v8-to-istanbul: 1.0.5 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -8798,9 +8781,9 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10) + '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11) '@vitest/expect@3.2.4': dependencies: @@ -8810,59 +8793,59 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.1.10': + '@vitest/expect@4.1.11': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.10 + '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.1.10': + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.1 - '@vitest/runner@4.1.10': + '@vitest/runner@4.1.11': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 pathe: 2.0.3 - '@vitest/snapshot@4.1.10': + '@vitest/snapshot@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 - '@vitest/utils': 4.1.10 + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 '@vitest/spy@3.2.4': dependencies: - tinyspy: 4.0.4 + tinyspy: 4.0.6 - '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} - '@vitest/ui@4.1.10(vitest@4.1.10)': + '@vitest/ui@4.1.11(vitest@4.1.11)': dependencies: - '@vitest/utils': 4.1.10 + '@vitest/utils': 4.1.11 fflate: 0.8.2 flatted: 3.4.4 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) '@vitest/utils@3.2.4': dependencies: @@ -8870,9 +8853,9 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.1.10': + '@vitest/utils@4.1.11': dependencies: - '@vitest/pretty-format': 4.1.10 + '@vitest/pretty-format': 4.1.11 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 @@ -8905,7 +8888,7 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.2.2: {} + ansi-regex@6.3.0: {} ansi-styles@4.3.0: dependencies: @@ -8948,7 +8931,7 @@ snapshots: assertion-error@2.0.1: {} - ast-types@0.16.1: + ast-types@0.16.3: dependencies: tslib: 2.8.1 @@ -8960,13 +8943,13 @@ snapshots: async@3.2.6: {} - autoprefixer@10.5.0(postcss@8.5.25): + autoprefixer@10.5.0(postcss@8.5.26): dependencies: browserslist: 4.28.2 caniuse-lite: 1.0.30001788 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -9147,7 +9130,7 @@ snapshots: chokidar@5.0.0: dependencies: - readdirp: 5.0.0 + readdirp: 5.1.1 cipher-base@1.0.7: dependencies: @@ -9185,7 +9168,7 @@ snapshots: colorette@2.0.20: {} - commander@14.0.3: {} + commander@15.0.0: {} commander@2.20.3: {} @@ -9267,21 +9250,21 @@ snapshots: randombytes: 2.1.0 randomfill: 1.0.4 - css-blank-pseudo@7.0.1(postcss@8.5.25): + css-blank-pseudo@7.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 - css-has-pseudo@7.0.3(postcss@8.5.25): + css-has-pseudo@7.0.3(postcss@8.5.26): dependencies: '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - css-prefers-color-scheme@10.0.0(postcss@8.5.25): + css-prefers-color-scheme@10.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 css-select@4.3.0: dependencies: @@ -9327,7 +9310,7 @@ snapshots: default-browser-id@5.0.1: {} - default-browser@5.5.0: + default-browser@5.5.1: dependencies: bundle-name: 4.1.0 default-browser-id: 5.0.1 @@ -9452,51 +9435,50 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@2.3.1: {} + es-module-lexer@2.3.2: {} es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} escape-string-regexp@4.0.0: {} - eslint-plugin-storybook@10.5.5(eslint@8.57.1(supports-color@7.2.0))(storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8))(supports-color@7.2.0)(typescript@5.9.3): + eslint-plugin-storybook@10.6.0(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/utils': 8.65.0(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/utils': 8.69.0(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) eslint: 8.57.1(supports-color@7.2.0) - storybook: 10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8) transitivePeerDependencies: - supports-color - typescript @@ -9586,19 +9568,19 @@ snapshots: md5.js: 1.3.5 safe-buffer: 5.2.1 - execa@9.6.1: + execa@10.0.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 - cross-spawn: 7.0.6 figures: 6.1.0 get-stream: 9.0.1 human-signals: 8.0.1 is-plain-obj: 4.1.0 is-stream: 4.0.1 npm-run-path: 6.0.0 - pretty-ms: 9.3.0 + pretty-ms: 9.3.1 signal-exit: 4.1.0 strip-final-newline: 4.0.0 + which-command: 0.1.0 yoctocolors: 2.2.0 expect-type@1.4.0: {} @@ -9874,24 +9856,24 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - i18next-cli@1.67.3(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(typescript@5.9.3): + i18next-cli@1.72.4(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(typescript@5.9.3): dependencies: '@croct/json5-parser': 0.2.2 - '@swc/core': 1.15.47 + '@swc/core': 1.16.2 chokidar: 5.0.0 - commander: 14.0.3 - execa: 9.6.1 + commander: 15.0.0 + execa: 10.0.1 glob: 10.5.0 - i18next: 26.3.6(typescript@5.9.3) + i18next: 26.4.1(typescript@5.9.3) i18next-resources-for-ts: 2.1.0 - inquirer: 14.0.2(@types/node@24.13.3) + inquirer: 14.2.1(@types/node@24.13.3) jiti: 2.7.0 jsonc-parser: 3.3.1 - magic-string: 0.30.21 + magic-string: 1.2.3 minimatch: 10.2.6 ora: 9.4.1 react: 19.2.8 - react-i18next: 17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + react-i18next: 17.0.13(i18next@26.4.1(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) yaml: 2.9.0 transitivePeerDependencies: - '@swc/helpers' @@ -9903,13 +9885,13 @@ snapshots: i18next-resources-for-ts@2.1.0: dependencies: '@babel/runtime': 7.29.7 - '@swc/core': 1.15.47 + '@swc/core': 1.16.2 chokidar: 5.0.0 yaml: 2.9.0 transitivePeerDependencies: - '@swc/helpers' - i18next@26.3.6(typescript@5.9.3): + i18next@26.4.1(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -9938,12 +9920,12 @@ snapshots: inherits@2.0.4: {} - inquirer@14.0.2(@types/node@24.13.3): + inquirer@14.2.1(@types/node@24.13.3): dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/core': 11.2.1(@types/node@24.13.3) - '@inquirer/prompts': 8.5.2(@types/node@24.13.3) - '@inquirer/type': 4.0.7(@types/node@24.13.3) + '@inquirer/ansi': 2.0.8 + '@inquirer/core': 12.0.2(@types/node@24.13.3) + '@inquirer/prompts': 8.7.1(@types/node@24.13.3) + '@inquirer/type': 4.1.1(@types/node@24.13.3) mute-stream: 3.0.0 run-async: 4.0.6 optionalDependencies: @@ -10268,6 +10250,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.2.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.4: dependencies: '@babel/parser': 7.29.8 @@ -10347,7 +10333,7 @@ snapshots: mute-stream@3.0.0: {} - nanoid@3.3.17: {} + nanoid@3.3.18: {} natural-compare@1.4.0: {} @@ -10451,7 +10437,7 @@ snapshots: open@10.2.0: dependencies: - default-browser: 5.5.0 + default-browser: 5.5.1 define-lazy-prop: 3.0.0 is-inside-container: 1.0.0 wsl-utils: 0.1.0 @@ -10529,51 +10515,51 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - oxc-resolver@11.24.2: + oxc-resolver@11.21.2: optionalDependencies: - '@oxc-resolver/binding-android-arm-eabi': 11.24.2 - '@oxc-resolver/binding-android-arm64': 11.24.2 - '@oxc-resolver/binding-darwin-arm64': 11.24.2 - '@oxc-resolver/binding-darwin-x64': 11.24.2 - '@oxc-resolver/binding-freebsd-x64': 11.24.2 - '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 - '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 - '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 - '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 - '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 - '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 - '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 - '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 - '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 - '@oxc-resolver/binding-linux-x64-musl': 11.24.2 - '@oxc-resolver/binding-openharmony-arm64': 11.24.2 - '@oxc-resolver/binding-wasm32-wasi': 11.24.2 - '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 - '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + '@oxc-resolver/binding-android-arm-eabi': 11.21.2 + '@oxc-resolver/binding-android-arm64': 11.21.2 + '@oxc-resolver/binding-darwin-arm64': 11.21.2 + '@oxc-resolver/binding-darwin-x64': 11.21.2 + '@oxc-resolver/binding-freebsd-x64': 11.21.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.21.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.21.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.21.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.21.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.21.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.21.2 + '@oxc-resolver/binding-linux-x64-musl': 11.21.2 + '@oxc-resolver/binding-openharmony-arm64': 11.21.2 + '@oxc-resolver/binding-wasm32-wasi': 11.21.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.21.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.21.2 - oxfmt@0.61.0: + oxfmt@0.66.0: dependencies: tinypool: 2.1.0 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.61.0 - '@oxfmt/binding-android-arm64': 0.61.0 - '@oxfmt/binding-darwin-arm64': 0.61.0 - '@oxfmt/binding-darwin-x64': 0.61.0 - '@oxfmt/binding-freebsd-x64': 0.61.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.61.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.61.0 - '@oxfmt/binding-linux-arm64-gnu': 0.61.0 - '@oxfmt/binding-linux-arm64-musl': 0.61.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.61.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.61.0 - '@oxfmt/binding-linux-riscv64-musl': 0.61.0 - '@oxfmt/binding-linux-s390x-gnu': 0.61.0 - '@oxfmt/binding-linux-x64-gnu': 0.61.0 - '@oxfmt/binding-linux-x64-musl': 0.61.0 - '@oxfmt/binding-openharmony-arm64': 0.61.0 - '@oxfmt/binding-win32-arm64-msvc': 0.61.0 - '@oxfmt/binding-win32-ia32-msvc': 0.61.0 - '@oxfmt/binding-win32-x64-msvc': 0.61.0 + '@oxfmt/binding-android-arm-eabi': 0.66.0 + '@oxfmt/binding-android-arm64': 0.66.0 + '@oxfmt/binding-darwin-arm64': 0.66.0 + '@oxfmt/binding-darwin-x64': 0.66.0 + '@oxfmt/binding-freebsd-x64': 0.66.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.66.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.66.0 + '@oxfmt/binding-linux-arm64-gnu': 0.66.0 + '@oxfmt/binding-linux-arm64-musl': 0.66.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.66.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.66.0 + '@oxfmt/binding-linux-riscv64-musl': 0.66.0 + '@oxfmt/binding-linux-s390x-gnu': 0.66.0 + '@oxfmt/binding-linux-x64-gnu': 0.66.0 + '@oxfmt/binding-linux-x64-musl': 0.66.0 + '@oxfmt/binding-openharmony-arm64': 0.66.0 + '@oxfmt/binding-win32-arm64-msvc': 0.66.0 + '@oxfmt/binding-win32-ia32-msvc': 0.66.0 + '@oxfmt/binding-win32-x64-msvc': 0.66.0 oxlint-tsgolint@0.25.0: optionalDependencies: @@ -10584,27 +10570,27 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.25.0 '@oxlint-tsgolint/win32-x64': 0.25.0 - oxlint@1.76.0(oxlint-tsgolint@0.25.0): + oxlint@1.81.0(oxlint-tsgolint@0.25.0): optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.76.0 - '@oxlint/binding-android-arm64': 1.76.0 - '@oxlint/binding-darwin-arm64': 1.76.0 - '@oxlint/binding-darwin-x64': 1.76.0 - '@oxlint/binding-freebsd-x64': 1.76.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.76.0 - '@oxlint/binding-linux-arm-musleabihf': 1.76.0 - '@oxlint/binding-linux-arm64-gnu': 1.76.0 - '@oxlint/binding-linux-arm64-musl': 1.76.0 - '@oxlint/binding-linux-ppc64-gnu': 1.76.0 - '@oxlint/binding-linux-riscv64-gnu': 1.76.0 - '@oxlint/binding-linux-riscv64-musl': 1.76.0 - '@oxlint/binding-linux-s390x-gnu': 1.76.0 - '@oxlint/binding-linux-x64-gnu': 1.76.0 - '@oxlint/binding-linux-x64-musl': 1.76.0 - '@oxlint/binding-openharmony-arm64': 1.76.0 - '@oxlint/binding-win32-arm64-msvc': 1.76.0 - '@oxlint/binding-win32-ia32-msvc': 1.76.0 - '@oxlint/binding-win32-x64-msvc': 1.76.0 + '@oxlint/binding-android-arm-eabi': 1.81.0 + '@oxlint/binding-android-arm64': 1.81.0 + '@oxlint/binding-darwin-arm64': 1.81.0 + '@oxlint/binding-darwin-x64': 1.81.0 + '@oxlint/binding-freebsd-x64': 1.81.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.81.0 + '@oxlint/binding-linux-arm-musleabihf': 1.81.0 + '@oxlint/binding-linux-arm64-gnu': 1.81.0 + '@oxlint/binding-linux-arm64-musl': 1.81.0 + '@oxlint/binding-linux-ppc64-gnu': 1.81.0 + '@oxlint/binding-linux-riscv64-gnu': 1.81.0 + '@oxlint/binding-linux-riscv64-musl': 1.81.0 + '@oxlint/binding-linux-s390x-gnu': 1.81.0 + '@oxlint/binding-linux-x64-gnu': 1.81.0 + '@oxlint/binding-linux-x64-musl': 1.81.0 + '@oxlint/binding-openharmony-arm64': 1.81.0 + '@oxlint/binding-win32-arm64-msvc': 1.81.0 + '@oxlint/binding-win32-ia32-msvc': 1.81.0 + '@oxlint/binding-win32-x64-msvc': 1.81.0 oxlint-tsgolint: 0.25.0 p-limit@2.3.0: @@ -10710,15 +10696,17 @@ snapshots: picomatch@4.0.5: {} + picomatch@4.0.7: {} + pkg-dir@5.0.0: dependencies: find-up: 5.0.0 - playwright-core@1.62.0: {} + playwright-core@1.62.1: {} - playwright@1.62.0: + playwright@1.62.1: dependencies: - playwright-core: 1.62.0 + playwright-core: 1.62.1 optionalDependencies: fsevents: 2.3.2 @@ -10728,224 +10716,224 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-attribute-case-insensitive@7.0.1(postcss@8.5.25): + postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 - postcss-clamp@4.1.0(postcss@8.5.25): + postcss-clamp@4.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-color-functional-notation@7.0.12(postcss@8.5.25): + postcss-color-functional-notation@7.0.12(postcss@8.5.26): dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - postcss-color-hex-alpha@10.0.0(postcss@8.5.25): + postcss-color-hex-alpha@10.0.0(postcss@8.5.26): dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-color-rebeccapurple@10.0.0(postcss@8.5.25): + postcss-color-rebeccapurple@10.0.0(postcss@8.5.26): dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-custom-media@11.0.6(postcss@8.5.25): + postcss-custom-media@11.0.6(postcss@8.5.26): dependencies: '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - postcss: 8.5.25 + postcss: 8.5.26 - postcss-custom-properties@14.0.6(postcss@8.5.25): + postcss-custom-properties@14.0.6(postcss@8.5.26): dependencies: '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-custom-selectors@8.0.5(postcss@8.5.25): + postcss-custom-selectors@8.0.5(postcss@8.5.26): dependencies: '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 - postcss-dir-pseudo-class@9.0.1(postcss@8.5.25): + postcss-dir-pseudo-class@9.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 - postcss-double-position-gradients@6.0.4(postcss@8.5.25): + postcss-double-position-gradients@6.0.4(postcss@8.5.26): dependencies: - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-focus-visible@10.0.1(postcss@8.5.25): + postcss-focus-visible@10.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 - postcss-focus-within@9.0.1(postcss@8.5.25): + postcss-focus-within@9.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 - postcss-font-variant@5.0.0(postcss@8.5.25): + postcss-font-variant@5.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-gap-properties@6.0.0(postcss@8.5.25): + postcss-gap-properties@6.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-image-set-function@7.0.0(postcss@8.5.25): + postcss-image-set-function@7.0.0(postcss@8.5.26): dependencies: - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-lab-function@7.0.12(postcss@8.5.25): + postcss-lab-function@7.0.12(postcss@8.5.26): dependencies: '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/utilities': 2.0.0(postcss@8.5.25) - postcss: 8.5.25 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/utilities': 2.0.0(postcss@8.5.26) + postcss: 8.5.26 - postcss-logical@8.1.0(postcss@8.5.25): + postcss-logical@8.1.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-nesting@13.0.2(postcss@8.5.25): + postcss-nesting@13.0.2(postcss@8.5.26): dependencies: '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.1) '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 - postcss-opacity-percentage@3.0.0(postcss@8.5.25): + postcss-opacity-percentage@3.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-overflow-shorthand@6.0.0(postcss@8.5.25): + postcss-overflow-shorthand@6.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-page-break@3.0.4(postcss@8.5.25): + postcss-page-break@3.0.4(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-place@10.0.0(postcss@8.5.25): + postcss-place@10.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-value-parser: 4.2.0 - postcss-preset-env@10.6.1(postcss@8.5.25): + postcss-preset-env@10.6.1(postcss@8.5.26): dependencies: - '@csstools/postcss-alpha-function': 1.0.1(postcss@8.5.25) - '@csstools/postcss-cascade-layers': 5.0.2(postcss@8.5.25) - '@csstools/postcss-color-function': 4.0.12(postcss@8.5.25) - '@csstools/postcss-color-function-display-p3-linear': 1.0.1(postcss@8.5.25) - '@csstools/postcss-color-mix-function': 3.0.12(postcss@8.5.25) - '@csstools/postcss-color-mix-variadic-function-arguments': 1.0.2(postcss@8.5.25) - '@csstools/postcss-content-alt-text': 2.0.8(postcss@8.5.25) - '@csstools/postcss-contrast-color-function': 2.0.12(postcss@8.5.25) - '@csstools/postcss-exponential-functions': 2.0.9(postcss@8.5.25) - '@csstools/postcss-font-format-keywords': 4.0.0(postcss@8.5.25) - '@csstools/postcss-gamut-mapping': 2.0.11(postcss@8.5.25) - '@csstools/postcss-gradients-interpolation-method': 5.0.12(postcss@8.5.25) - '@csstools/postcss-hwb-function': 4.0.12(postcss@8.5.25) - '@csstools/postcss-ic-unit': 4.0.4(postcss@8.5.25) - '@csstools/postcss-initial': 2.0.1(postcss@8.5.25) - '@csstools/postcss-is-pseudo-class': 5.0.3(postcss@8.5.25) - '@csstools/postcss-light-dark-function': 2.0.11(postcss@8.5.25) - '@csstools/postcss-logical-float-and-clear': 3.0.0(postcss@8.5.25) - '@csstools/postcss-logical-overflow': 2.0.0(postcss@8.5.25) - '@csstools/postcss-logical-overscroll-behavior': 2.0.0(postcss@8.5.25) - '@csstools/postcss-logical-resize': 3.0.0(postcss@8.5.25) - '@csstools/postcss-logical-viewport-units': 3.0.4(postcss@8.5.25) - '@csstools/postcss-media-minmax': 2.0.9(postcss@8.5.25) - '@csstools/postcss-media-queries-aspect-ratio-number-values': 3.0.5(postcss@8.5.25) - '@csstools/postcss-nested-calc': 4.0.0(postcss@8.5.25) - '@csstools/postcss-normalize-display-values': 4.0.1(postcss@8.5.25) - '@csstools/postcss-oklab-function': 4.0.12(postcss@8.5.25) - '@csstools/postcss-position-area-property': 1.0.0(postcss@8.5.25) - '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.25) - '@csstools/postcss-property-rule-prelude-list': 1.0.0(postcss@8.5.25) - '@csstools/postcss-random-function': 2.0.1(postcss@8.5.25) - '@csstools/postcss-relative-color-syntax': 3.0.12(postcss@8.5.25) - '@csstools/postcss-scope-pseudo-class': 4.0.1(postcss@8.5.25) - '@csstools/postcss-sign-functions': 1.1.4(postcss@8.5.25) - '@csstools/postcss-stepped-value-functions': 4.0.9(postcss@8.5.25) - '@csstools/postcss-syntax-descriptor-syntax-production': 1.0.1(postcss@8.5.25) - '@csstools/postcss-system-ui-font-family': 1.0.0(postcss@8.5.25) - '@csstools/postcss-text-decoration-shorthand': 4.0.3(postcss@8.5.25) - '@csstools/postcss-trigonometric-functions': 4.0.9(postcss@8.5.25) - '@csstools/postcss-unset-value': 4.0.0(postcss@8.5.25) - autoprefixer: 10.5.0(postcss@8.5.25) + '@csstools/postcss-alpha-function': 1.0.1(postcss@8.5.26) + '@csstools/postcss-cascade-layers': 5.0.2(postcss@8.5.26) + '@csstools/postcss-color-function': 4.0.12(postcss@8.5.26) + '@csstools/postcss-color-function-display-p3-linear': 1.0.1(postcss@8.5.26) + '@csstools/postcss-color-mix-function': 3.0.12(postcss@8.5.26) + '@csstools/postcss-color-mix-variadic-function-arguments': 1.0.2(postcss@8.5.26) + '@csstools/postcss-content-alt-text': 2.0.8(postcss@8.5.26) + '@csstools/postcss-contrast-color-function': 2.0.12(postcss@8.5.26) + '@csstools/postcss-exponential-functions': 2.0.9(postcss@8.5.26) + '@csstools/postcss-font-format-keywords': 4.0.0(postcss@8.5.26) + '@csstools/postcss-gamut-mapping': 2.0.11(postcss@8.5.26) + '@csstools/postcss-gradients-interpolation-method': 5.0.12(postcss@8.5.26) + '@csstools/postcss-hwb-function': 4.0.12(postcss@8.5.26) + '@csstools/postcss-ic-unit': 4.0.4(postcss@8.5.26) + '@csstools/postcss-initial': 2.0.1(postcss@8.5.26) + '@csstools/postcss-is-pseudo-class': 5.0.3(postcss@8.5.26) + '@csstools/postcss-light-dark-function': 2.0.11(postcss@8.5.26) + '@csstools/postcss-logical-float-and-clear': 3.0.0(postcss@8.5.26) + '@csstools/postcss-logical-overflow': 2.0.0(postcss@8.5.26) + '@csstools/postcss-logical-overscroll-behavior': 2.0.0(postcss@8.5.26) + '@csstools/postcss-logical-resize': 3.0.0(postcss@8.5.26) + '@csstools/postcss-logical-viewport-units': 3.0.4(postcss@8.5.26) + '@csstools/postcss-media-minmax': 2.0.9(postcss@8.5.26) + '@csstools/postcss-media-queries-aspect-ratio-number-values': 3.0.5(postcss@8.5.26) + '@csstools/postcss-nested-calc': 4.0.0(postcss@8.5.26) + '@csstools/postcss-normalize-display-values': 4.0.1(postcss@8.5.26) + '@csstools/postcss-oklab-function': 4.0.12(postcss@8.5.26) + '@csstools/postcss-position-area-property': 1.0.0(postcss@8.5.26) + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.26) + '@csstools/postcss-property-rule-prelude-list': 1.0.0(postcss@8.5.26) + '@csstools/postcss-random-function': 2.0.1(postcss@8.5.26) + '@csstools/postcss-relative-color-syntax': 3.0.12(postcss@8.5.26) + '@csstools/postcss-scope-pseudo-class': 4.0.1(postcss@8.5.26) + '@csstools/postcss-sign-functions': 1.1.4(postcss@8.5.26) + '@csstools/postcss-stepped-value-functions': 4.0.9(postcss@8.5.26) + '@csstools/postcss-syntax-descriptor-syntax-production': 1.0.1(postcss@8.5.26) + '@csstools/postcss-system-ui-font-family': 1.0.0(postcss@8.5.26) + '@csstools/postcss-text-decoration-shorthand': 4.0.3(postcss@8.5.26) + '@csstools/postcss-trigonometric-functions': 4.0.9(postcss@8.5.26) + '@csstools/postcss-unset-value': 4.0.0(postcss@8.5.26) + autoprefixer: 10.5.0(postcss@8.5.26) browserslist: 4.28.2 - css-blank-pseudo: 7.0.1(postcss@8.5.25) - css-has-pseudo: 7.0.3(postcss@8.5.25) - css-prefers-color-scheme: 10.0.0(postcss@8.5.25) + css-blank-pseudo: 7.0.1(postcss@8.5.26) + css-has-pseudo: 7.0.3(postcss@8.5.26) + css-prefers-color-scheme: 10.0.0(postcss@8.5.26) cssdb: 8.8.0 - postcss: 8.5.25 - postcss-attribute-case-insensitive: 7.0.1(postcss@8.5.25) - postcss-clamp: 4.1.0(postcss@8.5.25) - postcss-color-functional-notation: 7.0.12(postcss@8.5.25) - postcss-color-hex-alpha: 10.0.0(postcss@8.5.25) - postcss-color-rebeccapurple: 10.0.0(postcss@8.5.25) - postcss-custom-media: 11.0.6(postcss@8.5.25) - postcss-custom-properties: 14.0.6(postcss@8.5.25) - postcss-custom-selectors: 8.0.5(postcss@8.5.25) - postcss-dir-pseudo-class: 9.0.1(postcss@8.5.25) - postcss-double-position-gradients: 6.0.4(postcss@8.5.25) - postcss-focus-visible: 10.0.1(postcss@8.5.25) - postcss-focus-within: 9.0.1(postcss@8.5.25) - postcss-font-variant: 5.0.0(postcss@8.5.25) - postcss-gap-properties: 6.0.0(postcss@8.5.25) - postcss-image-set-function: 7.0.0(postcss@8.5.25) - postcss-lab-function: 7.0.12(postcss@8.5.25) - postcss-logical: 8.1.0(postcss@8.5.25) - postcss-nesting: 13.0.2(postcss@8.5.25) - postcss-opacity-percentage: 3.0.0(postcss@8.5.25) - postcss-overflow-shorthand: 6.0.0(postcss@8.5.25) - postcss-page-break: 3.0.4(postcss@8.5.25) - postcss-place: 10.0.0(postcss@8.5.25) - postcss-pseudo-class-any-link: 10.0.1(postcss@8.5.25) - postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.25) - postcss-selector-not: 8.0.1(postcss@8.5.25) + postcss: 8.5.26 + postcss-attribute-case-insensitive: 7.0.1(postcss@8.5.26) + postcss-clamp: 4.1.0(postcss@8.5.26) + postcss-color-functional-notation: 7.0.12(postcss@8.5.26) + postcss-color-hex-alpha: 10.0.0(postcss@8.5.26) + postcss-color-rebeccapurple: 10.0.0(postcss@8.5.26) + postcss-custom-media: 11.0.6(postcss@8.5.26) + postcss-custom-properties: 14.0.6(postcss@8.5.26) + postcss-custom-selectors: 8.0.5(postcss@8.5.26) + postcss-dir-pseudo-class: 9.0.1(postcss@8.5.26) + postcss-double-position-gradients: 6.0.4(postcss@8.5.26) + postcss-focus-visible: 10.0.1(postcss@8.5.26) + postcss-focus-within: 9.0.1(postcss@8.5.26) + postcss-font-variant: 5.0.0(postcss@8.5.26) + postcss-gap-properties: 6.0.0(postcss@8.5.26) + postcss-image-set-function: 7.0.0(postcss@8.5.26) + postcss-lab-function: 7.0.12(postcss@8.5.26) + postcss-logical: 8.1.0(postcss@8.5.26) + postcss-nesting: 13.0.2(postcss@8.5.26) + postcss-opacity-percentage: 3.0.0(postcss@8.5.26) + postcss-overflow-shorthand: 6.0.0(postcss@8.5.26) + postcss-page-break: 3.0.4(postcss@8.5.26) + postcss-place: 10.0.0(postcss@8.5.26) + postcss-pseudo-class-any-link: 10.0.1(postcss@8.5.26) + postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.26) + postcss-selector-not: 8.0.1(postcss@8.5.26) - postcss-pseudo-class-any-link@10.0.1(postcss@8.5.25): + postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 - postcss-replace-overflow-wrap@4.0.0(postcss@8.5.25): + postcss-replace-overflow-wrap@4.0.0(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 - postcss-selector-not@8.0.1(postcss@8.5.25): + postcss-selector-not@8.0.1(postcss@8.5.26): dependencies: - postcss: 8.5.25 + postcss: 8.5.26 postcss-selector-parser: 7.1.1 postcss-selector-parser@7.1.1: @@ -10955,9 +10943,9 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.25: + postcss@8.5.26: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -10988,7 +10976,7 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 - pretty-ms@9.3.0: + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 @@ -11063,22 +11051,22 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 - react-i18next@16.6.6(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + react-i18next@16.6.6(i18next@26.4.1(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.2 html-parse-stringify: 3.0.1 - i18next: 26.3.6(typescript@5.9.3) + i18next: 26.4.1(typescript@5.9.3) react: 19.2.8 use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: react-dom: 19.2.8(react@19.2.8) typescript: 5.9.3 - react-i18next@17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): + react-i18next@17.0.13(i18next@26.4.1(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.7 html-parse-stringify: 4.0.1 - i18next: 26.3.6(typescript@5.9.3) + i18next: 26.4.1(typescript@5.9.3) react: 19.2.8 use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: @@ -11089,32 +11077,32 @@ snapshots: react-is@17.0.2: {} - react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.8): + react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 - react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.8): + react-remove-scroll@2.7.2(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 - react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.8) - react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.8) + react-remove-scroll-bar: 2.3.8(@types/react@19.2.18)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.18)(react@19.2.8) tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.8) - use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.8) + use-callback-ref: 1.3.3(@types/react@19.2.18)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.2.18)(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - react-router-dom@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-router-dom@7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - react-router: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-router: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + react-router@7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: cookie: 1.1.1 react: 19.2.8 @@ -11122,13 +11110,13 @@ snapshots: optionalDependencies: react-dom: 19.2.8(react@19.2.8) - react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.8): + react-style-singleton@2.2.3(@types/react@19.2.18)(react@19.2.8): dependencies: get-nonce: 1.0.1 react: 19.2.8 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 react-use-measure@2.1.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: @@ -11158,11 +11146,11 @@ snapshots: dependencies: picomatch: 2.3.2 - readdirp@5.0.0: {} + readdirp@5.1.1: {} - recast@0.23.19: + recast@0.23.21: dependencies: - ast-types: 0.16.1 + ast-types: 0.16.3 esprima: 4.0.1 source-map: 0.6.1 tiny-invariant: 1.3.3 @@ -11206,26 +11194,26 @@ snapshots: hash-base: 3.1.2 inherits: 2.0.4 - rolldown@1.1.5: + rolldown@1.2.7: dependencies: - '@oxc-project/types': 0.139.0 + '@oxc-project/types': 0.148.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.5 - '@rolldown/binding-darwin-arm64': 1.1.5 - '@rolldown/binding-darwin-x64': 1.1.5 - '@rolldown/binding-freebsd-x64': 1.1.5 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 - '@rolldown/binding-linux-arm64-gnu': 1.1.5 - '@rolldown/binding-linux-arm64-musl': 1.1.5 - '@rolldown/binding-linux-ppc64-gnu': 1.1.5 - '@rolldown/binding-linux-s390x-gnu': 1.1.5 - '@rolldown/binding-linux-x64-gnu': 1.1.5 - '@rolldown/binding-linux-x64-musl': 1.1.5 - '@rolldown/binding-openharmony-arm64': 1.1.5 - '@rolldown/binding-wasm32-wasi': 1.1.5 - '@rolldown/binding-win32-arm64-msvc': 1.1.5 - '@rolldown/binding-win32-x64-msvc': 1.1.5 + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 rollup@4.60.1: dependencies: @@ -11285,7 +11273,7 @@ snapshots: safer-buffer@2.1.2: {} - sass@1.102.0: + sass@1.103.1: dependencies: chokidar: 5.0.0 immutable: 5.1.9 @@ -11398,27 +11386,27 @@ snapshots: stdin-discarder@0.3.2: {} - storybook@10.5.5(@types/react@19.2.17)(prettier@3.8.3)(react@19.2.8): + storybook@10.6.0(@types/react@19.2.18)(prettier@3.8.3)(react@19.2.8): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.1.0(react@19.2.8) '@testing-library/dom': 10.4.1 '@testing-library/jest-dom': 6.9.1 - '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@testing-library/user-event': 14.6.7(@testing-library/dom@10.4.1) '@vitest/expect': 3.2.4 '@vitest/spy': 3.2.4 '@webcontainer/env': 1.1.1 - esbuild: 0.28.1 + esbuild: 0.28.2 jsonc-parser: 3.3.1 open: 10.2.0 oxc-parser: 0.127.0 - oxc-resolver: 11.24.2 - recast: 0.23.19 + oxc-resolver: 11.21.2 + recast: 0.23.21 semver: 7.8.5 use-sync-external-store: 1.6.0(react@19.2.8) - ws: 8.21.1 + ws: 8.21.3 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 prettier: 3.8.3 transitivePeerDependencies: - bufferutil @@ -11468,7 +11456,7 @@ snapshots: strip-ansi@7.2.0: dependencies: - ansi-regex: 6.2.2 + ansi-regex: 6.3.0 strip-bom@3.0.0: {} @@ -11513,7 +11501,7 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.3.0: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: dependencies: @@ -11526,7 +11514,7 @@ snapshots: tinyrainbow@3.1.1: {} - tinyspy@4.0.4: {} + tinyspy@4.0.6: {} tldts-core@6.1.86: {} @@ -11626,7 +11614,7 @@ snapshots: dependencies: '@jridgewell/remapping': 2.3.5 acorn: 8.18.0 - picomatch: 4.0.5 + picomatch: 4.0.7 webpack-virtual-modules: 0.6.2 update-browserslist-db@1.2.3(browserslist@4.28.2): @@ -11644,20 +11632,20 @@ snapshots: punycode: 1.4.1 qs: 6.15.3 - use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.8): + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.8): + use-sidecar@1.1.3(@types/react@19.2.18)(react@19.2.8): dependencies: detect-node-es: 1.1.0 react: 19.2.8 tslib: 2.8.1 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 use-sync-external-store@1.6.0(react@19.2.8): dependencies: @@ -11678,11 +11666,11 @@ snapshots: is-typed-array: 1.1.15 which-typed-array: 1.1.20 - uuid@14.0.1: {} + uuid@14.0.2: {} - vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + vaul@1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: - '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: @@ -11696,7 +11684,7 @@ snapshots: mime-types: 2.1.35 picocolors: 1.1.1 - vite-plugin-html@3.2.2(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)): + vite-plugin-html@3.2.2(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)): dependencies: '@rollup/pluginutils': 4.2.1 colorette: 2.0.20 @@ -11710,91 +11698,91 @@ snapshots: html-minifier-terser: 6.1.0 node-html-parser: 5.4.2 pathe: 0.2.0 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) - vite-plugin-node-polyfills@0.28.0(rollup@4.60.1)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)): + vite-plugin-node-polyfills@0.28.0(rollup@4.60.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)): dependencies: '@rollup/plugin-inject': 5.0.5(rollup@4.60.1) node-stdlib-browser: 1.3.1 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) transitivePeerDependencies: - rollup - vite-plugin-node-stdlib-browser@0.2.1(node-stdlib-browser@1.3.1)(rollup@4.60.1)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)): + vite-plugin-node-stdlib-browser@0.2.1(node-stdlib-browser@1.3.1)(rollup@4.60.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)): dependencies: '@rollup/plugin-inject': 5.0.5(rollup@4.60.1) node-stdlib-browser: 1.3.1 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) transitivePeerDependencies: - rollup - vite-plugin-svgr@4.5.0(rollup@4.60.1)(supports-color@7.2.0)(typescript@5.9.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)): + vite-plugin-svgr@4.5.0(rollup@4.60.1)(supports-color@7.2.0)(typescript@5.9.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.60.1) '@svgr/core': 8.1.0(supports-color@7.2.0)(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(supports-color@7.2.0)(typescript@5.9.3))(supports-color@7.2.0) - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) transitivePeerDependencies: - rollup - supports-color - typescript - vite-plugin-wasm@3.6.0(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)): + vite-plugin-wasm@3.6.0(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)): dependencies: - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) - vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0): + vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 - picomatch: 4.0.5 - postcss: 8.5.25 - rolldown: 1.1.5 + picomatch: 4.0.7 + postcss: 8.5.26 + rolldown: 1.2.7 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 - esbuild: 0.28.1 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 - sass: 1.102.0 + sass: 1.103.1 terser: 5.46.1 yaml: 2.9.0 - vitest-axe@1.0.0-pre.5(vitest@4.1.10): + vitest-axe@1.0.0-pre.5(vitest@4.1.11): dependencies: '@vitest/pretty-format': 3.2.4 axe-core: 4.11.3 chalk: 5.6.2 lodash-es: 4.18.1 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0(supports-color@7.2.0))(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.10 - '@vitest/runner': 4.1.10 - '@vitest/snapshot': 4.1.10 - '@vitest/spy': 4.1.10 - '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.1 + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.5 + picomatch: 4.0.7 std-env: 4.2.0 tinybench: 2.9.0 - tinyexec: 1.3.0 + tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0) + vite: 8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.13.3 - '@vitest/browser-playwright': 4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10) - '@vitest/coverage-v8': 4.1.10(@vitest/browser@4.1.10)(vitest@4.1.10) - '@vitest/ui': 4.1.10(vitest@4.1.10) + '@vitest/browser-playwright': 4.1.11(playwright@1.62.1)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/coverage-v8': 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) + '@vitest/ui': 4.1.11(vitest@4.1.11) jsdom: 26.1.0(supports-color@7.2.0) transitivePeerDependencies: - msw @@ -11841,6 +11829,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-command@0.1.0: {} + which-module@2.0.1: {} which-typed-array@1.1.20: @@ -11888,6 +11878,8 @@ snapshots: ws@8.21.1: {} + ws@8.21.3: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 From ddc5a505293fa8657f6e19ecc28480c8f77cbbc7 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Tue, 8 Sep 2026 11:29:36 +0200 Subject: [PATCH 060/154] Disable new react compiler rules --- .oxlintrc.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.oxlintrc.json b/.oxlintrc.json index d06f2a47..c89ab8a2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -39,6 +39,17 @@ "jsdoc/check-property-names": "error", "jsdoc/require-param-description": "warn", "react/display-name": "error", + // TODO: These React Compiler-based rules joined oxlint's correctness + // category in v1.81 and flag many pre-existing patterns, including hooks + // like useLatest/useInitial/useReactiveState that intentionally access + // refs during render. Re-evaluate them when adopting the React Compiler. + "react/immutability": "off", + "react/no-deriving-state-in-effects": "off", + "react/preserve-manual-memoization": "off", + "react/refs": "off", + "react/set-state-in-effect": "off", + "react/static-components": "off", + "react/use-memo": "off", // TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness. // "rxjs/no-exposed-subjects": "error", // "rxjs/finnish": [ From a010cc983f588b602cf67baecd267a71d5bc571e Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Tue, 8 Sep 2026 13:49:19 +0200 Subject: [PATCH 061/154] Compare the component's config by value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything downstream of the component's params — the mute state, the call view model and with it the media connection — is keyed on the identity of the params object, which was memoised on the identity of the `config` prop. A host writing `config={{ ... }}` inline, which is the natural way to write it, therefore tore the whole call down on every render. The harness happened to pass a constant, so nothing noticed. `useStableValue` hands out the same object for as long as a deep comparison says nothing changed, so an inline config costs nothing. Co-Authored-By: Claude Fable 5.1 --- component/index.tsx | 14 +++++++-- src/useStableValue.test.ts | 64 ++++++++++++++++++++++++++++++++++++++ src/useStableValue.ts | 29 +++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/useStableValue.test.ts create mode 100644 src/useStableValue.ts diff --git a/component/index.tsx b/component/index.tsx index 84a18353..b58782be 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -66,6 +66,7 @@ import { type ConfigOptions } from "../src/config/ConfigOptions"; import { i18n } from "../src/utils/i18n"; import { useTheme } from "../src/useTheme"; import { useInitial } from "../src/useInitial"; +import { useStableValue } from "../src/useStableValue"; import styles from "./ElementCall.module.css"; // Everything needed to implement a HostBridge, not just the interface itself @@ -115,6 +116,9 @@ export interface ElementCallProps { * How Element Call should behave, overriding whatever {@link intent} implies. * A host that finds itself setting a lot of these probably wants a different * intent instead. + * + * Compared by value, so it is fine to write this inline; only a change to + * what it says restarts anything. */ config?: ElementCallConfiguration; /** @@ -180,14 +184,20 @@ export const ElementCall: FC = ({ // Element Call has no URL of its own to read any of this from, and the // host's URL is not Element Call's business, so the defaults come from the // intent with the host's wishes over the top. + // + // Everything downstream — the mute state, the call view model and with it + // the media connection — is keyed on the identity of this object, so it has + // to be stable for as long as its contents are. A host writing `config` + // inline would otherwise tear the call down on every render. + const stableConfig = useStableValue(config); const params = useMemo( (): UrlParams => ({ ...hostedProperties, roomId, ...configurationForIntent(intent), - ...config, + ...stableConfig, }), - [roomId, intent, config], + [roomId, intent, stableConfig], ); const mediaDevices = useInitial( diff --git a/src/useStableValue.test.ts b/src/useStableValue.test.ts new file mode 100644 index 00000000..8759cbaf --- /dev/null +++ b/src/useStableValue.test.ts @@ -0,0 +1,64 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { renderHook } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; + +import { useStableValue } from "./useStableValue"; + +describe("useStableValue", () => { + test("keeps the first identity while the contents stay equal", () => { + const first = { skipLobby: true, fonts: ["Inter"] }; + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value), + { initialProps: { value: first } }, + ); + expect(result.current).toBe(first); + + rerender({ value: { skipLobby: true, fonts: ["Inter"] } }); + expect(result.current).toBe(first); + }); + + test("takes the new identity once the contents change", () => { + const first = { skipLobby: true }; + const second = { skipLobby: false }; + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value), + { initialProps: { value: first } }, + ); + + rerender({ value: second }); + expect(result.current).toBe(second); + + // And that identity is then the stable one + rerender({ value: { skipLobby: false } }); + expect(result.current).toBe(second); + }); + + test("handles undefined, for an optional prop left out", () => { + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value), + { initialProps: { value: undefined as { a: number } | undefined } }, + ); + expect(result.current).toBeUndefined(); + + const given = { a: 1 }; + rerender({ value: given }); + expect(result.current).toBe(given); + }); + + test("accepts its own notion of equality", () => { + const first = { id: 1, label: "a" }; + const { result, rerender } = renderHook( + ({ value }) => useStableValue(value, (a, b) => a.id === b.id), + { initialProps: { value: first } }, + ); + + rerender({ value: { id: 1, label: "b" } }); + expect(result.current).toBe(first); + }); +}); diff --git a/src/useStableValue.ts b/src/useStableValue.ts new file mode 100644 index 00000000..89083216 --- /dev/null +++ b/src/useStableValue.ts @@ -0,0 +1,29 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { useState } from "react"; +import { isEqual } from "lodash-es"; + +/** + * Returns a value whose identity only changes when its contents do. + * + * For a prop that a caller is likely to write inline — an options object, say + * — so that a fresh but equal object on every render does not restart whatever + * depends on it. Deep equality by default. + */ +export function useStableValue( + value: T, + equals: (a: T, b: T) => boolean = isEqual, +): T { + const [stable, setStable] = useState(value); + if (equals(stable, value)) return stable; + // Setting state during render makes React re-run this render immediately + // with the new state, at which point the two are identical and the stored + // one is returned — so the identity handed out is consistent. + setStable(value); + return value; +} From 608f107f8d59a5be925467238023b95b745fa7b2 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Tue, 8 Sep 2026 13:50:17 +0200 Subject: [PATCH 062/154] End the component's media device scope on unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component built its `MediaDevices` once, in a scope nothing ever ended, so every mount left device observers running for the rest of the page's life. Building it in an effect ties the scope to the component's lifetime — and to the options it was built with, which were previously frozen at first render. Co-Authored-By: Claude Fable 5.1 --- component/index.tsx | 79 +++++++++++++++++++++++++++------------------ 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/component/index.tsx b/component/index.tsx index b58782be..a64cee65 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -32,7 +32,14 @@ Please see LICENSE in the repository root for full details. // sits in a `@layer`, which loses to unlayered rules either way. import "../src/base.css"; -import { type FC, type JSX, type ReactNode, useMemo, useState } from "react"; +import { + type FC, + type JSX, + type ReactNode, + useEffect, + useMemo, + useState, +} from "react"; import { type MatrixClient } from "matrix-js-sdk"; import { logger } from "matrix-js-sdk/lib/logger"; import { MemoryRouter } from "react-router-dom"; @@ -65,7 +72,6 @@ import { Config } from "../src/config/Config"; import { type ConfigOptions } from "../src/config/ConfigOptions"; import { i18n } from "../src/utils/i18n"; import { useTheme } from "../src/useTheme"; -import { useInitial } from "../src/useInitial"; import { useStableValue } from "../src/useStableValue"; import styles from "./ElementCall.module.css"; @@ -200,13 +206,22 @@ export const ElementCall: FC = ({ [roomId, intent, stableConfig], ); - const mediaDevices = useInitial( - () => - new MediaDevices(new ObservableScope(), { - controlledAudioDevices: params.controlledAudioDevices, - callIntent: params.callIntent, - }), - ); + // Created in an effect so that the scope it lives in ends when the component + // is unmounted (or these options change), rather than keeping its device + // observers running for the rest of the page's life. Null until then, which + // is one render. + const { controlledAudioDevices, callIntent } = params; + const [mediaDevices, setMediaDevices] = useState(null); + useEffect(() => { + const scope = new ObservableScope(); + setMediaDevices( + new MediaDevices(scope, { controlledAudioDevices, callIntent }), + ); + return (): void => { + setMediaDevices(null); + scope.end(); + }; + }, [controlledAudioDevices, callIntent]); const room = client.getRoom(roomId); const rtcSession = useMemo( @@ -227,28 +242,30 @@ export const ElementCall: FC = ({ embedded cannot disturb the host's URL. */}
    - {container !== null && rtcSession !== null && ( - - - - - - - - - - - - - - )} + {container !== null && + rtcSession !== null && + mediaDevices !== null && ( + + + + + + + + + + + + + + )}
    From bc58aed0d7443bc5cbdcf22a56d6cbd4efa4339f Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Tue, 8 Sep 2026 13:52:34 +0200 Subject: [PATCH 063/154] Keep keyboard shortcuts within Element Call's root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The call's shortcuts were listened for on the window and allowed whenever focus was inside the standalone app's `#root` — which, for a component embedded in a host, is the host's own root, or nothing. So m, v and space fired while the user typed in the host's composer, and two Element Calls on one page both answered every key. Listen on the element Element Call treats as its root instead (the body standalone, so nothing changes there), and judge whether a key press is spoken for by what has focus — a dialog or a text field — rather than by where it sits in the DOM, since the modals are now portalled to that same root. Co-Authored-By: Claude Fable 5.1 --- src/useCallViewKeyboardShortcuts.test.tsx | 63 +++++++++++++++++- src/useCallViewKeyboardShortcuts.ts | 80 ++++++++++++++--------- 2 files changed, 109 insertions(+), 34 deletions(-) diff --git a/src/useCallViewKeyboardShortcuts.test.tsx b/src/useCallViewKeyboardShortcuts.test.tsx index b002c23e..fddba503 100644 --- a/src/useCallViewKeyboardShortcuts.test.tsx +++ b/src/useCallViewKeyboardShortcuts.test.tsx @@ -18,6 +18,7 @@ import { ReactionsRowSize, } from "./reactions"; import { type Controls } from "./controls"; +import { RootElementProvider } from "./RootElementContext"; // Test Explanation: // - The main objective is to test `useCallViewKeyboardShortcuts`. @@ -48,10 +49,11 @@ const TestComponent: FC = ({ ); return ( <> -
    +
    - {/*// modal lives outside of the root*/} + {/* A dialog, which is what claims key presses for itself; where it + lives in the DOM does not matter */} {modalOpen && ( { // container element that can be interactive and receive focus / keydown // events.
    @@ -333,6 +356,7 @@ export const Harness: FC = (): ReactNode => { key={session.label} session={session} roomId={state.roomId} + language={language} log={log} /> ))} diff --git a/component/index.tsx b/component/index.tsx index 41c58483..90532966 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -50,6 +50,8 @@ import { ErrorBoundary } from "@sentry/react"; import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill"; import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js"; +import LanguageDetector from "i18next-browser-languagedetector"; + import EN from "../locales/en/app.json"; import { CallView } from "../src/room/CallView"; import { ErrorPage } from "../src/FullScreenView"; @@ -81,6 +83,10 @@ import { type ElementCallHostBridge, useComponentHostBridge, } from "./host"; +import { supportedLanguages, translationsBackend } from "./localization"; + +// The languages Element Call can be shown in +export { supportedLanguages } from "./localization"; // How the host and Element Call talk to each other, and what they say export { type ElementCallHandle, type ElementCallHostBridge } from "./host"; @@ -152,6 +158,15 @@ export interface ElementCallProps { * Available once the component has rendered. */ ref?: Ref; + /** + * The language to show Element Call in, as a BCP 47 tag: one of + * {@link supportedLanguages}, or something that falls back to one (`de-AT` + * to `de`). Left out, the browser's language is used. + * + * Translations are one thing shared by every Element Call on the page, so + * the most recently set language wins for all of them. + */ + language?: string; } /** @@ -171,22 +186,29 @@ export async function initializeElementCall( await Promise.all(polyfills); Config.initWith(config); - await i18n.init({ - fallbackLng: "en", - defaultNS: "app", - keySeparator: ".", - nsSeparator: false, - pluralSeparator: "_", - contextSeparator: "|", - lng: "en", - interpolation: { escapeValue: false }, - // English only, bundled in. The standalone app fetches its locale files at - // runtime from URLs its own build emits, which a host serving the library - // from elsewhere could not resolve; bundling one language at least keeps - // the component self-contained. Letting a host supply the rest, or its own - // translations, is still to do. - resources: { en: { app: EN } }, - }); + await i18n + .use(translationsBackend) + .use(new LanguageDetector()) + .init({ + fallbackLng: "en", + defaultNS: "app", + keySeparator: ".", + nsSeparator: false, + pluralSeparator: "_", + contextSeparator: "|", + supportedLngs: [...supportedLanguages], + interpolation: { escapeValue: false }, + // English is bundled in, so the fallback never has to be loaded; every + // other language arrives from the backend when first asked for. + partialBundledLanguages: true, + resources: { en: { app: EN } }, + detection: { + // The browser's language, until the host says otherwise through the + // `language` prop. Nothing is remembered: the choice is the host's. + order: ["navigator"], + caches: [], + }, + }); } /** Applies the theme and background to the container, before it is painted. */ @@ -207,9 +229,17 @@ export const ElementCall: FC = ({ config, hostBridge: suppliedHostBridge, ref, + language, }): ReactNode => { const hostBridge = useComponentHostBridge(suppliedHostBridge, ref); + useEffect(() => { + if (language !== undefined) + i18n + .changeLanguage(language) + .catch((e) => logger.error(`Could not switch to ${language}`, e)); + }, [language]); + // The container is what Element Call decorates and portals into, so nothing // inside can render until we have it. const [container, setContainer] = useState(null); diff --git a/component/localization.test.ts b/component/localization.test.ts new file mode 100644 index 00000000..fae1c706 --- /dev/null +++ b/component/localization.test.ts @@ -0,0 +1,46 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, test } from "vitest"; + +import { supportedLanguages, translationsBackend } from "./localization"; + +const read = async ( + language: string, + namespace = "app", +): Promise> => + await new Promise((resolve, reject) => + translationsBackend.read(language, namespace, (error, data) => { + if (error) reject(error); + else resolve(data as Record); + }), + ); + +describe("component translations", () => { + test("offer every language in locales/, tagged as its directory is", () => { + expect(supportedLanguages).toContain("en"); + expect(supportedLanguages).toContain("de"); + expect(supportedLanguages).toContain("zh-Hans"); + expect(new Set(supportedLanguages).size).toBe(supportedLanguages.length); + }); + + test("load a language's translations on demand", async () => { + const de = await read("de"); + expect(de).toHaveProperty("action"); + expect(de).not.toEqual(await read("en")); + }); + + test("refuse a language there are no translations for", async () => { + await expect(read("xx")).rejects.toThrow("No app translations for xx"); + }); + + test("refuse a namespace there are no translations for", async () => { + await expect(read("en", "other")).rejects.toThrow( + "No other translations for en", + ); + }); +}); diff --git a/component/localization.ts b/component/localization.ts new file mode 100644 index 00000000..b6417281 --- /dev/null +++ b/component/localization.ts @@ -0,0 +1,55 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +/** + * Translations for Element Call as a component. + * + * The standalone app fetches its locale files at runtime from URLs its own + * build emits, which a host serving the library from somewhere else could not + * resolve. The component instead has the bundler split every locale into a + * chunk of its own, loaded the first time its language is asked for; English, + * the fallback, is bundled in so that the first paint never waits for it. + */ + +import { type BackendModule, type ResourceKey } from "i18next"; + +import { languageOfLocalePath } from "../src/utils/i18n"; + +/** Every locale, as a lazily imported module. */ +const translations = import.meta.glob<{ default: ResourceKey }>( + "../locales/*/app.json", +); + +/** + * The languages Element Call can be shown in, as BCP 47 tags — `en`, `de`, + * `zh-Hans` and so on. A language that is not one of these falls back to its + * base language where there is one (`de-AT` to `de`), and to English otherwise. + */ +export const supportedLanguages: readonly string[] = [ + ...new Set(Object.keys(translations).map(languageOfLocalePath)), +]; + +/** Loads translations on demand. */ +export const translationsBackend: BackendModule = { + type: "backend", + init(): void {}, + read(language: string, namespace: string, callback): void { + const load = translations[`../locales/${language}/${namespace}.json`]; + if (load === undefined) { + callback(new Error(`No ${namespace} translations for ${language}`), null); + return; + } + load().then( + (module) => callback(null, module.default), + (error: unknown) => + callback( + error instanceof Error ? error : new Error(String(error)), + null, + ), + ); + }, +}; diff --git a/src/initializer.tsx b/src/initializer.tsx index 253dcbc4..76a7fcc3 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -37,7 +37,7 @@ import { type AnalyticsConfig, PosthogAnalytics, } from "./analytics/PosthogAnalytics.ts"; -import { i18n } from "./utils/i18n.ts"; +import { i18n, languageOfLocalePath } from "./utils/i18n.ts"; // This generates a map of locale names to their URL (based on import.meta.url), which looks like this: // { @@ -56,17 +56,7 @@ const getLocaleUrl = ( ): string | undefined => locales[`../locales/${language}/${namespace}.json`]; const supportedLngs = [ - ...new Set( - Object.keys(locales).map((url) => { - // The URLs are of the form ../locales/en/app.json - // This extracts the language code from the URL - const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1]; - if (!lang) { - throw new Error(`Could not parse locale URL ${url}`); - } - return lang; - }), - ), + ...new Set(Object.keys(locales).map(languageOfLocalePath)), ]; // A backend that fetches the locale files from the URLs generated by the glob above diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 65dca697..0796ab38 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -10,6 +10,17 @@ import i18next, { type i18n as I18nInstance } from "i18next"; // Custom marker function to allow i18next extraction export const i18nKey = (key: string): string => key; +/** + * The language a locale file under `locales/` is for, from its path as a + * bundler glob reports it: `../locales/zh-Hans/app.json` is for `zh-Hans`. + */ +export function languageOfLocalePath(path: string): string { + const language = path.match(/\/([^/]+)\/[^/]+\.json$/)?.[1]; + if (language === undefined) + throw new Error(`Could not parse locale path ${path}`); + return language; +} + /** * Element Call's own i18next instance. * From b722cc277e77bafa79bc00ea130e58c2cc58c083 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Tue, 8 Sep 2026 17:02:24 +0200 Subject: [PATCH 076/154] Make the theme a prop, next to the language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme is state — what Element Call should look like right now — and so belongs beside `language` as a prop, not on the imperative handle (where it was a request, `setTheme`, because the internal host bridge speaks the widget API and a widget's host sends theme changes as requests) and not in the configuration (where `config.theme` only ever set the starting theme). The `theme` prop feeds the same channel the rest of Element Call listens to for a host's theme, replayed so that whatever subscribes after the host has set it still hears the current one. Changing it re-themes the container and nothing else; unlike the language, it is per component. `setTheme` and `config.theme` are gone, and the harness gets a theme picker in place of its per-pane buttons. Co-Authored-By: Claude Fable 5.1 --- README.md | 3 ++- component/dev/Harness.tsx | 31 +++++++++++----------- component/host.test.ts | 56 ++++++++++++++++++++++++++++----------- component/host.ts | 22 ++++++++++----- component/index.tsx | 25 ++++++++++------- 5 files changed, 89 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index b1c46275..b2760290 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,8 @@ The component speaks every language the app does. English is bundled in; the other locales are split into chunks the host's bundler loads the first time they are needed. It starts in the browser's language, and follows the host's own language setting through the `language` prop (`supportedLanguages` lists -the tags it accepts). +the tags it accepts). The `theme` prop works the same way for `light` and +`dark`; both can change while a call is running without disturbing it. The package is not published yet. A host installs it as a git dependency on the `component` directory of this repository, diff --git a/component/dev/Harness.tsx b/component/dev/Harness.tsx index 9e40e2c7..ded9155a 100644 --- a/component/dev/Harness.tsx +++ b/component/dev/Harness.tsx @@ -94,9 +94,10 @@ interface LogEntry { const Pane: FC<{ session: Session; roomId: string; + theme: string | undefined; language: string | undefined; log: (pane: string, message: string) => void; -}> = ({ session, roomId, language, log }): ReactNode => { +}> = ({ session, roomId, theme, language, log }): ReactNode => { const [mounted, setMounted] = useState(true); const bridge = useMemo( @@ -139,20 +140,6 @@ const Pane: FC<{ - - +