diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index f78dc01..d57760e 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -28,6 +28,21 @@ jobs:
- name: Install Deps
run: "yarn install --frozen-lockfile"
+ - name: Get installed Playwright version
+ id: playwright
+ run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT
+
+ - name: Cache playwright binaries
+ uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
+ id: playwright-cache
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell
+
+ - name: Install Playwright browsers
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
+ run: "yarn playwright install --with-deps --only-shell"
+
- name: Run tests
run: yarn test
diff --git a/knip.ts b/knip.ts
index a2d4f42..f29cd5d 100644
--- a/knip.ts
+++ b/knip.ts
@@ -7,6 +7,9 @@ Please see LICENSE files in the repository root for full details.
import { KnipConfig } from "knip";
+// Specify this as knip loads config files which may conditionally add reporters, e.g. `vitest-sonar-reporter`
+process.env.GITHUB_ACTIONS = "1";
+
export default {
ignoreDependencies: [
// Needed for lint:workflows
diff --git a/modules/banner/element-web/package.json b/modules/banner/element-web/package.json
index c78181b..df45f81 100644
--- a/modules/banner/element-web/package.json
+++ b/modules/banner/element-web/package.json
@@ -17,7 +17,7 @@
"@types/node": "^22.10.7",
"@types/react": "^19",
"@vitejs/plugin-react": "^5.0.0",
- "react": "^19",
+ "react": "19.2.4",
"rollup-plugin-external-globals": "^0.13.0",
"typescript": "^5.7.3",
"vite": "^7.1.11",
diff --git a/modules/opendesk/element-web/package.json b/modules/opendesk/element-web/package.json
index 653df09..c0fa58b 100644
--- a/modules/opendesk/element-web/package.json
+++ b/modules/opendesk/element-web/package.json
@@ -22,8 +22,8 @@
"@types/node": "^22.10.7",
"@types/react": "^19",
"@vitejs/plugin-react": "^5.0.0",
- "react": "^19",
- "react-dom": "^19",
+ "react": "19.2.4",
+ "react-dom": "19.2.4",
"rollup-plugin-external-globals": "^0.13.0",
"typescript": "^5.7.3",
"vite": "^7.1.11",
diff --git a/modules/restricted-guests/element-web/package.json b/modules/restricted-guests/element-web/package.json
index 8587318..dfe5c0f 100644
--- a/modules/restricted-guests/element-web/package.json
+++ b/modules/restricted-guests/element-web/package.json
@@ -17,7 +17,7 @@
"@types/node": "^22.10.7",
"@types/react": "^19",
"@vitejs/plugin-react": "^5.0.0",
- "react": "^19",
+ "react": "19.2.4",
"rollup-plugin-external-globals": "^0.13.0",
"typescript": "^5.7.3",
"vite": "^7.1.11",
diff --git a/modules/widget-toggles/README.md b/modules/widget-toggles/README.md
new file mode 100644
index 0000000..e117e70
--- /dev/null
+++ b/modules/widget-toggles/README.md
@@ -0,0 +1,16 @@
+# Widget Toggles Module
+
+Adds room header buttons for widgets in the room.
+
+This module needs to be configured to control what widget types get buttons added for them.
+The following config snippet enables the module and configures it to add buttons for both
+custom and jitsi widgets:
+
+```
+"modules": [
+ "/modules/widget-toggles/lib/index.js"
+],
+"io.element.element-web-modules.widget-toggles": {
+ "types": ["m.custom", "jitsi"]
+}
+```
diff --git a/modules/widget-toggles/element-web/e2e/fixture/widget.html b/modules/widget-toggles/element-web/e2e/fixture/widget.html
new file mode 100644
index 0000000..c2c28e4
--- /dev/null
+++ b/modules/widget-toggles/element-web/e2e/fixture/widget.html
@@ -0,0 +1,5 @@
+
+
+ This is the content of the widget
+
+
diff --git a/modules/widget-toggles/element-web/e2e/snapshots/widget-toggles.spec.ts/widget-toggle-button-default-icon-linux.png b/modules/widget-toggles/element-web/e2e/snapshots/widget-toggles.spec.ts/widget-toggle-button-default-icon-linux.png
new file mode 100644
index 0000000..a171d0a
Binary files /dev/null and b/modules/widget-toggles/element-web/e2e/snapshots/widget-toggles.spec.ts/widget-toggle-button-default-icon-linux.png differ
diff --git a/modules/widget-toggles/element-web/e2e/widget-toggles.spec.ts b/modules/widget-toggles/element-web/e2e/widget-toggles.spec.ts
new file mode 100644
index 0000000..556408b
--- /dev/null
+++ b/modules/widget-toggles/element-web/e2e/widget-toggles.spec.ts
@@ -0,0 +1,170 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { type Page } from "@playwright/test";
+import { type Credentials } from "@element-hq/element-web-playwright-common/lib/utils/api";
+import { type StartedHomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/HomeserverContainer";
+import { type Container } from "@element-hq/element-web-module-api";
+
+import { test as base, expect } from "../../../../playwright/element-web-test.ts";
+
+const test = base.extend<{
+ // Resolver for when to respond to the navigation.json request
+ navigationJsonResolver: PromiseWithResolvers;
+}>({
+ navigationJsonResolver: async ({}, use) => {
+ await use(Promise.withResolvers());
+ },
+});
+
+const TEST_WIDGET_NAME = "Name of the test widget";
+
+async function makeRoomWithWidgetAndGoTo(
+ homeserver: StartedHomeserverContainer,
+ user: Credentials,
+ page: Page,
+ avatarUrl?: string,
+): Promise {
+ const { room_id: roomId } = await homeserver.csApi.request<{ room_id: string }>(
+ "POST",
+ "/v3/createRoom",
+ user.accessToken,
+ {
+ name: "Come on in we've got widgets",
+ },
+ );
+ await homeserver.csApi.request<{
+ event_id: string;
+ }>("PUT", `/v3/rooms/${encodeURIComponent(roomId)}/state/im.vector.modular.widgets/1`, user.accessToken, {
+ id: "1",
+ creatorUserId: user.userId,
+ type: "m.custom",
+ name: TEST_WIDGET_NAME,
+ url: `http://localhost:8080/widget.html`,
+ avatar_url: avatarUrl,
+ });
+
+ await page.goto(`/#/room/${roomId}`);
+
+ return roomId;
+}
+
+async function moveWidgetToContainer(
+ homeserver: StartedHomeserverContainer,
+ user: Credentials,
+ roomId: string,
+ container: Container,
+): Promise {
+ await homeserver.csApi.request(
+ "PUT",
+ `/v3/user/${encodeURIComponent(user.userId)}/rooms/${encodeURIComponent(roomId)}/account_data/im.vector.web.settings`,
+ user.accessToken,
+ {
+ "Widgets.layout": {
+ widgets: {
+ "1": { container },
+ },
+ },
+ },
+ );
+}
+
+test.describe("widget-toggles", () => {
+ test.use({
+ displayName: "Timmy",
+ page: async ({ context, page, moduleDir }, use) => {
+ await context.route("http://localhost:8080/widget.html*", async (route) => {
+ await route.fulfill({ path: `${moduleDir}/e2e/fixture/widget.html`, contentType: "text/html" });
+ });
+
+ await context.route("http://localhost:8080/wigeon.png", async (route) => {
+ await route.fulfill({ path: `${moduleDir}/e2e/fixture/wigeon.png`, contentType: "image/png" });
+ });
+
+ await page.goto("/");
+ await use(page);
+ },
+ });
+
+ test("should error if config is missing", async ({ page }) => {
+ await expect(page.getByText("Your Element is misconfigured")).toBeVisible();
+ await expect(page.getByText("Errors in module configuration")).toBeVisible();
+ // We don't take a screenshot as we don't want to assert Element's styling, only our own
+ });
+
+ test.describe("with correct config", () => {
+ test.use({
+ config: {
+ "io.element.element-web-modules.widget-toggles": {
+ types: ["m.custom"],
+ },
+ },
+ });
+
+ test(
+ "should render 'show' button for widget not in top",
+ { tag: ["@screenshot"] },
+ async ({ homeserver, page, user }) => {
+ await makeRoomWithWidgetAndGoTo(homeserver, user, page);
+
+ await expect(page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME })).toBeVisible();
+ },
+ );
+
+ test(
+ "should render 'hide' button for widget in top",
+ { tag: ["@screenshot"] },
+ async ({ homeserver, page, user }) => {
+ const roomId = await makeRoomWithWidgetAndGoTo(homeserver, user, page);
+
+ await moveWidgetToContainer(homeserver, user, roomId, "top");
+
+ await expect(page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME })).toBeVisible();
+ },
+ );
+
+ test("should move widget to top when 'show' button is clicked", async ({ homeserver, page, user }) => {
+ await makeRoomWithWidgetAndGoTo(homeserver, user, page);
+
+ await page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME }).click();
+ await expect(page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME })).toBeVisible();
+
+ await expect(page.locator('iframe[title="Name of the test widget"]')).toBeVisible();
+ });
+
+ test("should move widget to left when 'hide' button is clicked", async ({ homeserver, page, user }) => {
+ const roomId = await makeRoomWithWidgetAndGoTo(homeserver, user, page);
+ await moveWidgetToContainer(homeserver, user, roomId, "top");
+
+ await expect(page.locator('iframe[title="Name of the test widget"]')).toBeVisible();
+
+ await page.getByRole("button", { name: "Hide " + TEST_WIDGET_NAME }).click();
+
+ await expect(page.locator('iframe[title="Name of the test widget"]')).not.toBeVisible();
+ });
+
+ test("uses widget icon for button image if present", async ({ homeserver, page, user }) => {
+ await makeRoomWithWidgetAndGoTo(homeserver, user, page, "mxc://fakehomeserver/fake_content_id");
+
+ await expect(
+ page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME }).getByRole("img"),
+ ).toHaveAttribute("src", /\/_matrix\/media\/v3\/download\/fakehomeserver\/fake_content_id$/);
+ });
+
+ test(
+ "uses built in icon for widgets with no avatar",
+ { tag: ["@screenshot"] },
+ async ({ homeserver, page, user }) => {
+ await makeRoomWithWidgetAndGoTo(homeserver, user, page);
+
+ await expect(page.getByRole("button", { name: "Show " + TEST_WIDGET_NAME })).toMatchScreenshot(
+ "widget-toggle-button-default-icon.png",
+ );
+ },
+ );
+ });
+});
diff --git a/modules/widget-toggles/element-web/external_all.diff b/modules/widget-toggles/element-web/external_all.diff
new file mode 100644
index 0000000..ed1bc76
--- /dev/null
+++ b/modules/widget-toggles/element-web/external_all.diff
@@ -0,0 +1,28 @@
+diff --git a/modules/widget-toggles/element-web/vite.config.ts b/modules/widget-toggles/element-web/vite.config.ts
+index b65e3ec..9d1ba49 100644
+--- a/modules/widget-toggles/element-web/vite.config.ts
++++ b/modules/widget-toggles/element-web/vite.config.ts
+@@ -28,7 +28,14 @@ export default defineConfig({
+ target: "esnext",
+ sourcemap: true,
+ rollupOptions: {
+- external: ["react"],
++ // make sure to externalize deps that shouldn't be bundled
++ // into your library
++ external: [
++ "react",
++ "react-dom",
++ "@vector-im/compound-design-tokens",
++ "@vector-im/compound-web"
++ ],
+ },
+ },
+ plugins: [
+@@ -41,6 +48,7 @@ export default defineConfig({
+ externalGlobals({
+ // Reuse React from the host app
+ react: "window.React",
++ "@vector-im/compound-web": "window.CompoundWeb",
+ }),
+ ],
+ define: {
diff --git a/modules/widget-toggles/element-web/package.json b/modules/widget-toggles/element-web/package.json
new file mode 100644
index 0000000..b4f1a3e
--- /dev/null
+++ b/modules/widget-toggles/element-web/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "@element-hq/element-web-module-widget-toggle",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "main": "lib/index.js",
+ "license": "SEE LICENSE IN README.md",
+ "scripts": {
+ "prepare": "vite build",
+ "lint:types": "tsc --noEmit",
+ "lint:codestyle": "echo 'handled by lint:eslint'",
+ "test": "vitest run --coverage"
+ },
+ "devDependencies": {
+ "@arcmantle/vite-plugin-import-css-sheet": "^1.0.12",
+ "@element-hq/element-web-module-api": "^1.0.0",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/node": "^22.10.7",
+ "@types/react": "^19",
+ "@vitejs/plugin-react": "^5.0.0",
+ "@vitest/browser-playwright": "4.0.18",
+ "@vitest/coverage-v8": "^4.0.0",
+ "react": "^19",
+ "rollup-plugin-external-globals": "^0.13.0",
+ "typescript": "^5.7.3",
+ "vite": "^7.1.11",
+ "vite-plugin-node-polyfills": "^0.25.0",
+ "vite-plugin-svgr": "^4.3.0",
+ "vitest": "^4.0.0",
+ "vitest-sonar-reporter": "^2.0.0"
+ },
+ "dependencies": {
+ "@vector-im/compound-design-tokens": "^6.0.0",
+ "@vector-im/compound-web": "^8.0.0",
+ "matrix-widget-api": "^1.17.0",
+ "styled-components": "^6.3.11",
+ "zod": "^4.3.6"
+ }
+}
diff --git a/modules/widget-toggles/element-web/src/config.ts b/modules/widget-toggles/element-web/src/config.ts
new file mode 100644
index 0000000..54e2d07
--- /dev/null
+++ b/modules/widget-toggles/element-web/src/config.ts
@@ -0,0 +1,27 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { z, type input } from "zod/mini";
+
+z.config(z.locales.en());
+
+export const WidgetTogglesConfig = z.object({
+ /**
+ * The widget types to show a toggle for.
+ */
+ types: z.array(z.string()),
+});
+
+export type WidgetTogglesConfig = z.infer;
+
+export const CONFIG_KEY = "io.element.element-web-modules.widget-toggles";
+
+declare module "@element-hq/element-web-module-api" {
+ export interface Config {
+ [CONFIG_KEY]: input;
+ }
+}
diff --git a/modules/widget-toggles/element-web/src/index.tsx b/modules/widget-toggles/element-web/src/index.tsx
new file mode 100644
index 0000000..9527917
--- /dev/null
+++ b/modules/widget-toggles/element-web/src/index.tsx
@@ -0,0 +1,64 @@
+/*
+Copyright 2025 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { type JSX } from "react";
+import { TooltipProvider } from "@vector-im/compound-web";
+
+import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api";
+import { CONFIG_KEY, WidgetTogglesConfig } from "./config";
+import { WidgetToggle } from "./toggle";
+
+class WidgetToggleModule implements Module {
+ public static readonly moduleApiVersion = "^1.0.0";
+ private config?: WidgetTogglesConfig;
+
+ public constructor(private api: Api) {}
+
+ public async load(): Promise {
+ try {
+ this.config = WidgetTogglesConfig.parse(this.api.config.get(CONFIG_KEY));
+ } catch (e) {
+ console.error("Failed to init module", e);
+ throw new Error(`Errors in module configuration for widget toggles module`);
+ }
+
+ this.api.extras.addRoomHeaderButtonCallback((roomId: string) => {
+ const widgets = this.api.widget.getWidgetsInRoom(roomId);
+ const toggleElements: JSX.Element[] = [];
+
+ for (const widget of widgets) {
+ if (this.config?.types.includes(widget.type)) {
+ toggleElements.push(
+ ,
+ );
+ }
+ }
+
+ if (toggleElements.length === 0) return undefined;
+
+ // XXX: We shouldn't have to add another TooltipProvider here, it should
+ // be using the one in MatrixChat in Element Web, but thanks to the fact
+ // that contexts are "magically" identified by class, it doesn't pick up the
+ // context because we use a different copy of compound-web. We'll probably
+ // need to fix this at some point, possibly by moving compound's tooltip stuff
+ // out to its own mini-module that can be provided at runtime by Element Web,
+ // unless React make contexts more sensible.
+ // Annoyingly this does actually cause the tooltips to behave a bit weirdly:
+ // there's a delay before the tooltip appears when yo move between these buttons
+ // and the rest of the header buttons, whereas moving between the other header
+ // buttons, the tooltips appear straight away once one has appeared.
+ return {toggleElements};
+ });
+ }
+}
+
+export default WidgetToggleModule satisfies ModuleFactory;
diff --git a/modules/widget-toggles/element-web/src/toggle.tsx b/modules/widget-toggles/element-web/src/toggle.tsx
new file mode 100644
index 0000000..741904c
--- /dev/null
+++ b/modules/widget-toggles/element-web/src/toggle.tsx
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2024 Nordeck IT + Consulting GmbH
+ * Copyright 2026 Element Creations Ltd.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { type I18nApi, type WidgetApi } from "@element-hq/element-web-module-api";
+import { IconButton, Tooltip } from "@vector-im/compound-web";
+import { type IWidget } from "matrix-widget-api";
+import React, { type JSX } from "react";
+import styled from "styled-components";
+
+type Props = { $isInContainer: boolean };
+
+const Img = styled.img`
+ border-color: ${(): string => "var(--cpd-color-text-action-accent)"};
+ border-radius: 50%;
+ border-style: solid;
+ border-width: ${({ $isInContainer }): string => ($isInContainer ? "2px" : "0px")};
+ box-sizing: border-box;
+ height: 24px;
+ width: 24px;
+`;
+
+const Svg = styled.svg`
+ height: 24px;
+ fill: ${({ $isInContainer }): string => ($isInContainer ? "var(--cpd-color-text-action-accent)" : "currentColor")};
+ width: 24px;
+`;
+
+function avatarUrl(app: IWidget, widgetApi: WidgetApi): string | null {
+ if (app.type.match(/jitsi/i)) {
+ return "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyMCAyMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDxyZWN0IHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgcng9IjQiIGZpbGw9IiM1QUJGRjIiLz4KICAgIDxwYXRoIGQ9Ik0zIDcuODc1QzMgNi44Mzk0NyAzLjgzOTQ3IDYgNC44NzUgNkgxMS4xODc1QzEyLjIyMyA2IDEzLjA2MjUgNi44Mzk0NyAxMy4wNjI1IDcuODc1VjEyLjg3NUMxMy4wNjI1IDEzLjkxMDUgMTIuMjIzIDE0Ljc1IDExLjE4NzUgMTQuNzVINC44NzVDMy44Mzk0NyAxNC43NSAzIDEzLjkxMDUgMyAxMi44NzVWNy44NzVaIiBmaWxsPSJ3aGl0ZSIvPgogICAgPHBhdGggZD0iTTE0LjM3NSA4LjQ0NjQ0TDE2LjEyMDggNy4xMTAzOUMxNi40ODA2IDYuODM1MDIgMTcgNy4wOTE1OCAxNyA3LjU0NDY4VjEzLjAzOTZDMTcgMTMuNTE5OSAxNi40MjUxIDEzLjc2NjkgMTYuMDc2NyAxMy40MzYzTDE0LjM3NSAxMS44MjE0VjguNDQ2NDRaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K";
+ }
+
+ return widgetApi.getAppAvatarUrl(app);
+}
+
+function isInContainer(app: IWidget, widgetApi: WidgetApi, roomId: string): boolean {
+ return widgetApi.isAppInContainer(app, "center", roomId) || widgetApi.isAppInContainer(app, "top", roomId);
+}
+
+type WidgetToggleProps = {
+ app: IWidget;
+ roomId: string;
+ widgetApi: WidgetApi;
+ i18nApi: I18nApi;
+};
+
+export function WidgetToggle({ app, roomId, widgetApi, i18nApi }: WidgetToggleProps): JSX.Element {
+ const appAvatarUrl = avatarUrl(app, widgetApi);
+ const appNameOrType = app.name ?? app.type;
+ const inContainer = isInContainer(app, widgetApi, roomId);
+
+ const label = i18nApi.translate(inContainer ? "Hide %(name)s" : "Show %(name)s", { name: appNameOrType });
+
+ const onClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation();
+ if (inContainer) {
+ widgetApi.moveAppToContainer(app, "right", roomId);
+ } else {
+ widgetApi.moveAppToContainer(app, "top", roomId);
+ }
+ },
+ [app, inContainer, roomId, widgetApi],
+ );
+
+ return (
+
+
+ {appAvatarUrl ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/modules/widget-toggles/element-web/tests/config.test.ts b/modules/widget-toggles/element-web/tests/config.test.ts
new file mode 100644
index 0000000..7863ffa
--- /dev/null
+++ b/modules/widget-toggles/element-web/tests/config.test.ts
@@ -0,0 +1,43 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { describe, expect, test } from "vitest";
+
+import { WidgetTogglesConfig } from "../src/config";
+
+describe("WidgetTogglesConfig", () => {
+ test("parses a valid config with an array of widget types", () => {
+ const result = WidgetTogglesConfig.safeParse({ types: ["m.video", "m.audio"] });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.types).toEqual(["m.video", "m.audio"]);
+ }
+ });
+
+ test("parses a valid config with an empty types array", () => {
+ const result = WidgetTogglesConfig.safeParse({ types: [] });
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.types).toEqual([]);
+ }
+ });
+
+ test("rejects a config missing the types field", () => {
+ const result = WidgetTogglesConfig.safeParse({});
+ expect(result.success).toBe(false);
+ });
+
+ test("rejects a config where types is not an array", () => {
+ const result = WidgetTogglesConfig.safeParse({ types: "m.video" });
+ expect(result.success).toBe(false);
+ });
+
+ test("rejects a config where types contains non-string values", () => {
+ const result = WidgetTogglesConfig.safeParse({ types: [1, 2, 3] });
+ expect(result.success).toBe(false);
+ });
+});
diff --git a/modules/widget-toggles/element-web/tests/index.test.ts b/modules/widget-toggles/element-web/tests/index.test.ts
new file mode 100644
index 0000000..d1258e5
--- /dev/null
+++ b/modules/widget-toggles/element-web/tests/index.test.ts
@@ -0,0 +1,175 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { type Api } from "@element-hq/element-web-module-api";
+import { type IWidget } from "matrix-widget-api";
+
+import WidgetToggleModule from "../src/index";
+import { CONFIG_KEY, WidgetTogglesConfig } from "../src/config";
+import { mockWidget, mockWidgetApi } from "./mocks";
+
+const makeApi = (widgets: IWidget[] = []): Api => {
+ const addRoomHeaderButtonCallback = vi.fn();
+ return {
+ config: {
+ get: vi.fn().mockReturnValue({ types: ["m.custom"] }),
+ },
+ extras: {
+ addRoomHeaderButtonCallback,
+ },
+ widget: mockWidgetApi({
+ getWidgetsInRoom: vi.fn().mockReturnValue(widgets),
+ }),
+ i18n: {
+ translate: vi.fn().mockImplementation((key: string) => key),
+ },
+ } as unknown as Api;
+};
+
+vi.mock("../src/config", async () => {
+ return {
+ CONFIG_KEY: "fake_config_key",
+ WidgetTogglesConfig: {
+ parse: vi.fn(),
+ },
+ };
+});
+
+describe("WidgetToggleModule", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("load", () => {
+ test("reads config using CONFIG_KEY", async () => {
+ const api = makeApi();
+
+ const module = new WidgetToggleModule(api);
+ await module.load();
+
+ expect(api.config.get).toHaveBeenCalledWith(CONFIG_KEY);
+ });
+
+ test("parses config with WidgetTogglesConfig.parse", async () => {
+ const api = makeApi();
+ const rawConfig = { types: ["m.custom"] };
+ (api.config.get as ReturnType).mockReturnValue(rawConfig);
+
+ const module = new WidgetToggleModule(api);
+ await module.load();
+
+ expect(WidgetTogglesConfig.parse).toHaveBeenCalledWith(rawConfig);
+ });
+
+ test("registers a room header button callback", async () => {
+ const api = makeApi();
+ (WidgetTogglesConfig.parse as ReturnType).mockReturnValue({ types: ["m.custom"] });
+
+ const module = new WidgetToggleModule(api);
+ await module.load();
+
+ expect(api.extras.addRoomHeaderButtonCallback).toHaveBeenCalledOnce();
+ });
+
+ test("throws error when config parsing fails", async () => {
+ const api = makeApi();
+ (WidgetTogglesConfig.parse as ReturnType).mockImplementation(() => {
+ throw new Error("Invalid config");
+ });
+
+ const module = new WidgetToggleModule(api);
+ await expect(module.load()).rejects.toThrow("Errors in module configuration for widget toggles module");
+ });
+ });
+
+ describe("room header button callback", () => {
+ const roomId = "!room:example.com";
+
+ const getCallback = async (api: Api): Promise<(roomId: string) => React.JSX.Element | undefined> => {
+ (WidgetTogglesConfig.parse as ReturnType).mockReturnValue({ types: ["m.custom"] });
+ const module = new WidgetToggleModule(api);
+ await module.load();
+ return (api.extras.addRoomHeaderButtonCallback as ReturnType).mock.calls[0][0];
+ };
+
+ test("returns undefined when there are no widgets in the room", async () => {
+ const api = makeApi([]);
+ const callback = await getCallback(api);
+
+ const result = callback(roomId);
+ expect(result).toBeUndefined();
+ });
+
+ test("returns undefined when no widgets match the configured types", async () => {
+ const api = makeApi([mockWidget({ type: "m.other" })]);
+ const callback = await getCallback(api);
+
+ const result = callback(roomId);
+ expect(result).toBeUndefined();
+ });
+
+ test("renders WidgetToggle for each matching widget", async () => {
+ const api = makeApi([
+ mockWidget({ id: "w1", type: "m.custom", name: "Widget One" }),
+ mockWidget({ id: "w2", type: "m.custom", name: "Widget Two" }),
+ ]);
+ (api.i18n.translate as ReturnType).mockImplementation(
+ (key: string, vars?: Record) => {
+ let result = key;
+ if (vars) {
+ for (const [k, v] of Object.entries(vars)) {
+ result = result.replace(`%(${k})s`, v);
+ }
+ }
+ return result;
+ },
+ );
+ const callback = await getCallback(api);
+
+ const result = callback(roomId);
+ expect(result).toBeDefined();
+ render(result!);
+
+ expect(screen.getAllByRole("button").length).toBe(2);
+ });
+
+ test("does not render WidgetToggle for non-matching widget types", async () => {
+ const api = makeApi([
+ mockWidget({ id: "w1", type: "m.custom", name: "Widget One" }),
+ mockWidget({ id: "w2", type: "m.other", name: "Widget Other" }),
+ ]);
+ (api.i18n.translate as ReturnType).mockImplementation(
+ (key: string, vars?: Record) => {
+ let result = key;
+ if (vars) {
+ for (const [k, v] of Object.entries(vars)) {
+ result = result.replace(`%(${k})s`, v);
+ }
+ }
+ return result;
+ },
+ );
+ const callback = await getCallback(api);
+
+ const result = callback(roomId);
+ expect(result).toBeDefined();
+ render(result!);
+
+ expect(screen.getAllByRole("button").length).toBe(1);
+ });
+
+ test("calls getWidgetsInRoom with correct roomId", async () => {
+ const api = makeApi([]);
+ const callback = await getCallback(api);
+
+ callback(roomId);
+ expect(api.widget.getWidgetsInRoom).toHaveBeenCalledWith(roomId);
+ });
+ });
+});
diff --git a/modules/widget-toggles/element-web/tests/mocks.ts b/modules/widget-toggles/element-web/tests/mocks.ts
new file mode 100644
index 0000000..e37cd67
--- /dev/null
+++ b/modules/widget-toggles/element-web/tests/mocks.ts
@@ -0,0 +1,45 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { type I18nApi, type WidgetApi } from "@element-hq/element-web-module-api";
+import { type IWidget } from "matrix-widget-api";
+import { vi } from "vitest";
+
+export function mockWidget(overrides: Partial = {}): IWidget {
+ return {
+ id: "widget-1",
+ creatorUserId: "@user:example.com",
+ type: "m.custom",
+ name: "My Widget",
+ url: "https://example.com",
+ ...overrides,
+ };
+}
+
+export function mockWidgetApi(overrides: Partial = {}): WidgetApi {
+ return {
+ getWidgetsInRoom: vi.fn().mockReturnValue([]),
+ getAppAvatarUrl: vi.fn().mockReturnValue(null),
+ isAppInContainer: vi.fn().mockReturnValue(false),
+ moveAppToContainer: vi.fn(),
+ ...overrides,
+ } as unknown as WidgetApi;
+}
+
+export function mockI18nApi(): I18nApi {
+ return {
+ translate: vi.fn().mockImplementation((key: string, vars?: Record) => {
+ let result = key;
+ if (vars) {
+ for (const [k, v] of Object.entries(vars)) {
+ result = result.replace(`%(${k})s`, v);
+ }
+ }
+ return result;
+ }),
+ } as unknown as I18nApi;
+}
diff --git a/modules/widget-toggles/element-web/tests/setupTests.ts b/modules/widget-toggles/element-web/tests/setupTests.ts
new file mode 100644
index 0000000..d7601a1
--- /dev/null
+++ b/modules/widget-toggles/element-web/tests/setupTests.ts
@@ -0,0 +1,13 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { afterEach } from "vitest";
+import { cleanup } from "@testing-library/react";
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/modules/widget-toggles/element-web/tests/toggle.test.tsx b/modules/widget-toggles/element-web/tests/toggle.test.tsx
new file mode 100644
index 0000000..0a0e404
--- /dev/null
+++ b/modules/widget-toggles/element-web/tests/toggle.test.tsx
@@ -0,0 +1,82 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { beforeEach, describe, expect, test, type vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { type WidgetApi, type I18nApi } from "@element-hq/element-web-module-api";
+import { type IWidget } from "matrix-widget-api";
+import { type PropsWithChildren } from "react";
+import { TooltipProvider } from "@vector-im/compound-web";
+import userEvent from "@testing-library/user-event";
+
+import { WidgetToggle } from "../src/toggle";
+import { mockI18nApi, mockWidget, mockWidgetApi } from "./mocks";
+
+const roomId = "!room:example.com";
+
+const wrapper = ({ children }: PropsWithChildren): React.JSX.Element => {children};
+
+describe("WidgetToggle", () => {
+ let widgetApi: WidgetApi;
+ let i18nApi: I18nApi;
+ let app: IWidget;
+
+ beforeEach(() => {
+ widgetApi = mockWidgetApi();
+ i18nApi = mockI18nApi();
+ app = mockWidget();
+ });
+
+ test("displays avatar image when widget has an avatar URL", () => {
+ (widgetApi.getAppAvatarUrl as ReturnType).mockReturnValue("https://example.com/avatar.png");
+ render(, { wrapper });
+ const img = screen.getByRole("img", { name: app.name });
+ expect(img).toBeDefined();
+ expect(img.getAttribute("src")).toBe("https://example.com/avatar.png");
+ });
+
+ test("renders the Jitsi avatar for Jitsi widgets", () => {
+ app = mockWidget({ type: "m.jitsi", name: "Jitsi" });
+ render(, { wrapper });
+ const img = screen.getByRole("img", { name: "Jitsi" });
+ expect(img.getAttribute("src")).toMatch(/^data:image\/svg\+xml;base64,/);
+ });
+
+ test("shows 'Show' label when widget is not in container", () => {
+ (widgetApi.isAppInContainer as ReturnType).mockReturnValue(false);
+ render(, { wrapper });
+ const button = screen.getByRole("button", { name: "Show My Widget" });
+ expect(button).toBeDefined();
+ });
+
+ test("shows 'Hide' label when widget is in container", () => {
+ (widgetApi.isAppInContainer as ReturnType).mockReturnValue(true);
+ render(, { wrapper });
+ const button = screen.getByRole("button", { name: "Hide My Widget" });
+ expect(button).toBeDefined();
+ });
+
+ test("calls moveAppToContainer with 'top' when widget is not in container and button is clicked", async () => {
+ const user = userEvent.setup();
+
+ (widgetApi.isAppInContainer as ReturnType).mockReturnValue(false);
+ render(, { wrapper });
+ const button = screen.getByRole("button", { name: "Show My Widget" });
+ await user.click(button);
+ expect(widgetApi.moveAppToContainer).toHaveBeenCalledWith(app, "top", roomId);
+ });
+
+ test("calls moveAppToContainer with 'right' when widget is in container and button is clicked", async () => {
+ const user = userEvent.setup();
+
+ (widgetApi.isAppInContainer as ReturnType).mockReturnValue(true);
+ render(, { wrapper });
+ const button = screen.getByRole("button", { name: "Hide My Widget" });
+ await user.click(button);
+ expect(widgetApi.moveAppToContainer).toHaveBeenCalledWith(app, "right", roomId);
+ });
+});
diff --git a/modules/widget-toggles/element-web/tests/tsconfig.json b/modules/widget-toggles/element-web/tests/tsconfig.json
new file mode 100644
index 0000000..f5eb2ca
--- /dev/null
+++ b/modules/widget-toggles/element-web/tests/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "compilerOptions": {
+ "jsx": "react-jsx"
+ },
+ "include": ["."]
+}
diff --git a/modules/widget-toggles/element-web/tsconfig.json b/modules/widget-toggles/element-web/tsconfig.json
new file mode 100644
index 0000000..fbda638
--- /dev/null
+++ b/modules/widget-toggles/element-web/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "lib",
+ "jsx": "react-jsx"
+ },
+ "include": ["src"]
+}
diff --git a/modules/widget-toggles/element-web/vite.config.ts b/modules/widget-toggles/element-web/vite.config.ts
new file mode 100644
index 0000000..b65e3ec
--- /dev/null
+++ b/modules/widget-toggles/element-web/vite.config.ts
@@ -0,0 +1,51 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { nodePolyfills } from "vite-plugin-node-polyfills";
+import externalGlobals from "rollup-plugin-external-globals";
+import svgr from "vite-plugin-svgr";
+import { importCSSSheet } from "@arcmantle/vite-plugin-import-css-sheet";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+export default defineConfig({
+ build: {
+ lib: {
+ entry: resolve(__dirname, "src/index.tsx"),
+ name: "element-web-module-widget-toggles",
+ fileName: "index",
+ formats: ["es"],
+ },
+ outDir: "lib",
+ target: "esnext",
+ sourcemap: true,
+ rollupOptions: {
+ external: ["react"],
+ },
+ },
+ plugins: [
+ importCSSSheet(),
+ react(),
+ svgr(),
+ nodePolyfills({
+ include: ["events"],
+ }),
+ externalGlobals({
+ // Reuse React from the host app
+ react: "window.React",
+ }),
+ ],
+ define: {
+ // Use production mode for the build as it is tested against production builds of Element Web,
+ // this is required for React JSX versions to be compatible.
+ process: { env: { NODE_ENV: "production" } },
+ },
+});
diff --git a/modules/widget-toggles/element-web/vitest.config.ts b/modules/widget-toggles/element-web/vitest.config.ts
new file mode 100644
index 0000000..4a5b5a3
--- /dev/null
+++ b/modules/widget-toggles/element-web/vitest.config.ts
@@ -0,0 +1,34 @@
+/*
+Copyright 2026 Element Creations Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { defineConfig } from "vitest/config";
+import { env } from "node:process";
+import { playwright } from "@vitest/browser-playwright";
+
+const isGHA = env["GITHUB_ACTIONS"] !== undefined;
+
+export default defineConfig({
+ test: {
+ include: ["tests/**/*.test.{ts,tsx}"],
+ exclude: ["./e2e/**/*", "./node_modules/**/*"],
+ reporters: isGHA
+ ? ["default", ["vitest-sonar-reporter", { outputFile: "coverage/sonar-report.xml" }]]
+ : ["default"],
+ coverage: {
+ provider: "v8",
+ include: ["src/**/*.ts"],
+ reporter: [["lcov", { projectRoot: "../../../" }], "text"],
+ },
+ browser: {
+ enabled: true,
+ headless: true,
+ provider: playwright({}),
+ instances: [{ browser: "chromium" }],
+ },
+ setupFiles: ["tests/setupTests.ts"],
+ },
+});
diff --git a/sonar-project.properties b/sonar-project.properties
index 4fc4c58..3c35871 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -11,9 +11,12 @@ sonar.test.inclusions=\
**/*.spec.*
sonar.exclusions=**/*.test.*,**/*.api.md,**/e2e/fixture/**
sonar.coverage.exclusions=\
+ knip.ts,\
playwright.config.ts,\
**/vite.config.ts,\
+ **/vitest.config.ts,\
**/*playwright*/**/*,\
+ **/setupTests.ts, \
modules/banner/element-web/**/*,\
modules/restricted-guests/element-web/**/*
sonar.typescript.tsconfigPath=./tsconfig.json
diff --git a/yarn.lock b/yarn.lock
index ebdc3b8..5f85cff 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -45,6 +45,15 @@
js-tokens "^4.0.0"
picocolors "^1.0.0"
+"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0":
+ version "7.29.0"
+ resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c"
+ integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==
+ dependencies:
+ "@babel/helper-validator-identifier" "^7.28.5"
+ js-tokens "^4.0.0"
+ picocolors "^1.1.1"
+
"@babel/code-frame@^7.26.2", "@babel/code-frame@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be"
@@ -54,15 +63,6 @@
js-tokens "^4.0.0"
picocolors "^1.1.1"
-"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0":
- version "7.29.0"
- resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c"
- integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==
- dependencies:
- "@babel/helper-validator-identifier" "^7.28.5"
- js-tokens "^4.0.0"
- picocolors "^1.1.1"
-
"@babel/compat-data@^7.27.2":
version "7.27.2"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.2.tgz#4183f9e642fd84e74e3eea7ffa93a412e3b102c9"
@@ -438,6 +438,11 @@
dependencies:
regenerator-runtime "^0.14.0"
+"@babel/runtime@^7.12.5":
+ version "7.28.6"
+ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.6.tgz#d267a43cb1836dc4d182cce93ae75ba954ef6d2b"
+ integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==
+
"@babel/template@^7.26.9", "@babel/template@^7.27.1", "@babel/template@^7.27.2":
version "7.27.2"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
@@ -1304,6 +1309,11 @@
dependencies:
playwright "1.58.2"
+"@polka/url@^1.0.0-next.24":
+ version "1.0.0-next.29"
+ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
+ integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==
+
"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
@@ -2105,6 +2115,32 @@
dependencies:
testcontainers "^11.12.0"
+"@testing-library/dom@^10.4.1":
+ version "10.4.1"
+ resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95"
+ integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==
+ dependencies:
+ "@babel/code-frame" "^7.10.4"
+ "@babel/runtime" "^7.12.5"
+ "@types/aria-query" "^5.0.1"
+ aria-query "5.3.0"
+ dom-accessibility-api "^0.5.9"
+ lz-string "^1.5.0"
+ picocolors "1.1.1"
+ pretty-format "^27.0.2"
+
+"@testing-library/react@^16.3.2":
+ version "16.3.2"
+ resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.3.2.tgz#672883b7acb8e775fc0492d9e9d25e06e89786d0"
+ integrity sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==
+ dependencies:
+ "@babel/runtime" "^7.12.5"
+
+"@testing-library/user-event@^14.6.1":
+ version "14.6.1"
+ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149"
+ integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==
+
"@tybys/wasm-util@^0.10.1":
version "0.10.1"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
@@ -2117,6 +2153,11 @@
resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9"
integrity sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==
+"@types/aria-query@^5.0.1":
+ version "5.0.4"
+ resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708"
+ integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==
+
"@types/babel__core@^7.20.5":
version "7.20.5"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017"
@@ -2426,6 +2467,29 @@
"@types/babel__core" "^7.20.5"
react-refresh "^0.18.0"
+"@vitest/browser-playwright@4.0.18":
+ version "4.0.18"
+ resolved "https://registry.yarnpkg.com/@vitest/browser-playwright/-/browser-playwright-4.0.18.tgz#1a844a44cf2f1e2321ca70e405063104350e5472"
+ integrity sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==
+ dependencies:
+ "@vitest/browser" "4.0.18"
+ "@vitest/mocker" "4.0.18"
+ tinyrainbow "^3.0.3"
+
+"@vitest/browser@4.0.18":
+ version "4.0.18"
+ resolved "https://registry.yarnpkg.com/@vitest/browser/-/browser-4.0.18.tgz#9d826cc21f09c27f8fe758715a92a6a878236a02"
+ integrity sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==
+ dependencies:
+ "@vitest/mocker" "4.0.18"
+ "@vitest/utils" "4.0.18"
+ magic-string "^0.30.21"
+ pixelmatch "7.1.0"
+ pngjs "^7.0.0"
+ sirv "^3.0.2"
+ tinyrainbow "^3.0.3"
+ ws "^8.18.3"
+
"@vitest/coverage-v8@^4.0.0":
version "4.0.18"
resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz#b9c4db7479acd51d5f0ced91b2853c29c3d0cda7"
@@ -2660,6 +2724,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
dependencies:
color-convert "^2.0.1"
+ansi-styles@^5.0.0:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
+ integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
+
ansi-styles@^6.1.0:
version "6.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
@@ -2715,6 +2784,13 @@ aria-hidden@^1.2.4:
dependencies:
tslib "^2.0.0"
+aria-query@5.3.0:
+ version "5.3.0"
+ resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e"
+ integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==
+ dependencies:
+ dequal "^2.0.3"
+
aria-query@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59"
@@ -3582,6 +3658,11 @@ delayed-stream@~1.0.0:
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
+dequal@^2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
+ integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
+
des.js@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.1.0.tgz#1d37f5766f3bbff4ee9638e871a8768c173b81da"
@@ -3658,6 +3739,11 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
+dom-accessibility-api@^0.5.9:
+ version "0.5.16"
+ resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453"
+ integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==
+
domain-browser@4.22.0:
version "4.22.0"
resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-4.22.0.tgz#6ddd34220ec281f9a65d3386d267ddd35c491f9f"
@@ -5533,6 +5619,11 @@ lru-cache@^6.0.0:
dependencies:
yallist "^4.0.0"
+lz-string@^1.5.0:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
+ integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
+
magic-string@^0.30.10, magic-string@^0.30.17, magic-string@^0.30.3:
version "0.30.17"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453"
@@ -5735,6 +5826,11 @@ motion-utils@^12.29.2:
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.29.2.tgz#8fdd28babe042c2456b078ab33b32daa3bf5938b"
integrity sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==
+mrmime@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc"
+ integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==
+
ms@^2.1.1, ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
@@ -6141,7 +6237,7 @@ pbkdf2@^3.1.2:
sha.js "^2.4.11"
to-buffer "^1.2.0"
-picocolors@^1.0.0, picocolors@^1.1.1:
+picocolors@1.1.1, picocolors@^1.0.0, picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
@@ -6156,6 +6252,13 @@ picomatch@^4.0.1, picomatch@^4.0.2, picomatch@^4.0.3:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
+pixelmatch@7.1.0:
+ version "7.1.0"
+ resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-7.1.0.tgz#9d59bddc8c779340e791106c0f245ac33ae4d113"
+ integrity sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==
+ dependencies:
+ pngjs "^7.0.0"
+
pkg-dir@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-5.0.0.tgz#a02d6aebe6ba133a928f74aec20bafdfe6b8e760"
@@ -6200,6 +6303,11 @@ pluralize@^8.0.0:
resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1"
integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==
+pngjs@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-7.0.0.tgz#a8b7446020ebbc6ac739db6c5415a65d17090e26"
+ integrity sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==
+
possible-typed-array-names@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae"
@@ -6247,6 +6355,15 @@ prettier@^3.4.2:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
+pretty-format@^27.0.2:
+ version "27.5.1"
+ resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e"
+ integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==
+ dependencies:
+ ansi-regex "^5.0.1"
+ ansi-styles "^5.0.0"
+ react-is "^17.0.1"
+
process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
@@ -6380,7 +6497,7 @@ react-clientside-effect@^1.2.6:
dependencies:
"@babel/runtime" "^7.12.13"
-react-dom@^19:
+react-dom@19.2.4:
version "19.2.4"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591"
integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==
@@ -6404,6 +6521,11 @@ react-is@^16.13.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
+react-is@^17.0.1:
+ version "17.0.2"
+ resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
+ integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
+
react-refresh@^0.18.0:
version "0.18.0"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.18.0.tgz#2dce97f4fe932a4d8142fa1630e475c1729c8062"
@@ -6436,10 +6558,10 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
get-nonce "^1.0.0"
tslib "^2.0.0"
-react@^19:
- version "19.2.0"
- resolved "https://registry.yarnpkg.com/react/-/react-19.2.0.tgz#d33dd1721698f4376ae57a54098cb47fc75d93a5"
- integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==
+react@19.2.4, react@^19:
+ version "19.2.4"
+ resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a"
+ integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==
read-pkg-up@^7.0.1:
version "7.0.1"
@@ -6874,6 +6996,15 @@ signal-exit@^4.0.1, signal-exit@^4.1.0:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==
+sirv@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.2.tgz#f775fccf10e22a40832684848d636346f41cd970"
+ integrity sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==
+ dependencies:
+ "@polka/url" "^1.0.0-next.24"
+ mrmime "^2.0.0"
+ totalist "^3.0.0"
+
slash@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"
@@ -7193,7 +7324,7 @@ strip-json-comments@^3.1.1, strip-json-comments@~3.1.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
-styled-components@^6.1.18:
+styled-components@^6.1.18, styled-components@^6.3.11:
version "6.3.11"
resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-6.3.11.tgz#6babdb151a3419b0a79a368353d1dfbe8c5dddbc"
integrity sha512-opzgceGlQ5rdZdGwf9ddLW7EM2F4L7tgsgLn6fFzQ2JgE5EVQ4HZwNkcgB1p8WfOBx1GEZP3fa66ajJmtXhSrA==
@@ -7387,6 +7518,11 @@ to-regex-range@^5.0.1:
dependencies:
is-number "^7.0.0"
+totalist@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8"
+ integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==
+
ts-api-utils@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8"
@@ -7673,6 +7809,11 @@ vite@^7.1.11:
optionalDependencies:
fsevents "~2.3.3"
+vitest-sonar-reporter@^2.0.0:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/vitest-sonar-reporter/-/vitest-sonar-reporter-2.0.4.tgz#e81ae089364bea257f4da89b38599ae52535e41f"
+ integrity sha512-6mKFLXYzaHsuR+qnmuXXVhcjhostuicZ9iL3I325uf6sUKSZ2ZOpDWUBcgQwEmGs2xE05SPF6F72eoHmfOWX7A==
+
vitest-sonar-reporter@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/vitest-sonar-reporter/-/vitest-sonar-reporter-3.0.0.tgz#3bb34a9a46390dce83a50de16135fc325c73359d"
@@ -7934,7 +8075,7 @@ zod@^3.22.4:
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.12.tgz#64f1ea53d00eab91853195653b5af9eee68970f0"
integrity sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==
-zod@^4.0.0, zod@^4.1.11:
+zod@^4.0.0, zod@^4.1.11, zod@^4.3.6:
version "4.3.6"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a"
integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==