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/package.json b/modules/widget-toggles/element-web/package.json new file mode 100644 index 0000000..2a9c15e --- /dev/null +++ b/modules/widget-toggles/element-web/package.json @@ -0,0 +1,34 @@ +{ + "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": "echo no tests yet" + }, + "devDependencies": { + "@arcmantle/vite-plugin-import-css-sheet": "^1.0.12", + "@element-hq/element-web-module-api": "^1.0.0", + "@types/node": "^22.10.7", + "@types/react": "^19", + "@vitejs/plugin-react": "^5.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" + }, + "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..d9a8d99 --- /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.setRoomHeaderButtonCallback((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 ? ( + {appNameOrType} + ) : ( + + + + )} + + + ); +} 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/packages/element-web-module-api/element-web-module-api.api.md b/packages/element-web-module-api/element-web-module-api.api.md index 86cce8d..aff9156 100644 --- a/packages/element-web-module-api/element-web-module-api.api.md +++ b/packages/element-web-module-api/element-web-module-api.api.md @@ -5,6 +5,7 @@ ```ts import { ComponentType } from 'react'; +import { IWidget } from 'matrix-widget-api'; import { JSX } from 'react'; import { ModuleApi } from '@matrix-org/react-sdk-module-api'; import { Root } from 'react-dom/client'; @@ -56,6 +57,8 @@ export interface Api extends LegacyModuleApiExtension, LegacyCustomisationsApiEx readonly rootNode: HTMLElement; readonly stores: StoresApi; // @alpha + readonly widget: WidgetApi; + // @alpha readonly widgetLifecycle: WidgetLifecycleApi; } @@ -107,6 +110,9 @@ export interface ConfigApi { get(key?: K): Config | Config[K]; } +// @alpha +export type Container = "top" | "right" | "center"; + // @alpha export interface CustomComponentsApi { registerMessageRenderer(eventTypeOrFilter: string | ((mxEvent: MatrixEvent) => boolean), renderer: CustomMessageRenderFunction, hints?: CustomMessageRenderHints): void; @@ -174,6 +180,7 @@ export interface DirectoryCustomisations { // @alpha export interface ExtrasApi { getVisibleRoomBySpaceKey(spaceKey: string, cb: () => string[]): void; + setRoomHeaderButtonCallback(cb: RoomHeaderButtonsCallback): void; setSpacePanelItem(spaceKey: string, props: SpacePanelItemProps): void; } @@ -358,6 +365,9 @@ export interface Room { name: Watchable; } +// @alpha +export type RoomHeaderButtonsCallback = (roomId: string) => JSX.Element | undefined; + // @alpha @deprecated (undocumented) export interface RoomListCustomisations { isRoomVisible?(room: Room): boolean; @@ -436,6 +446,14 @@ export class Watchable { watch(listener: (value: T) => void): void; } +// @alpha +export interface WidgetApi { + getAppAvatarUrl(app: IWidget, width?: number, height?: number, resizeMethod?: string): string | null; + getWidgetsInRoom(roomId: string): IWidget[]; + isAppInContainer(app: IWidget, container: Container, roomId: string): boolean; + moveAppToContainer(app: IWidget, container: Container, roomId: string): void; +} + // @alpha export type WidgetDescriptor = { id: string; @@ -476,4 +494,3 @@ export interface WidgetVariablesCustomisations { // (No @packageDocumentation comment for this package) ``` - diff --git a/packages/element-web-module-api/package.json b/packages/element-web-module-api/package.json index 6950765..63a87ad 100644 --- a/packages/element-web-module-api/package.json +++ b/packages/element-web-module-api/package.json @@ -61,5 +61,8 @@ "matrix-web-i18n": { "optional": true } + }, + "dependencies": { + "matrix-widget-api": "^1.17.0" } } diff --git a/packages/element-web-module-api/src/api/extras.ts b/packages/element-web-module-api/src/api/extras.ts index 5ffc9c4..1f6f981 100644 --- a/packages/element-web-module-api/src/api/extras.ts +++ b/packages/element-web-module-api/src/api/extras.ts @@ -43,6 +43,15 @@ export interface SpacePanelItemProps { onSelected: () => void; } +/** + * A callback that returns a JSX element representing the buttons. + * + * @alpha + * @param roomId - The ID of the room for which the header is being rendered. + * @returns A JSX element representing the buttons to be rendered in the room header, or undefined if no buttons should be rendered. + */ +export type RoomHeaderButtonsCallback = (roomId: string) => JSX.Element | undefined; + /** * API for inserting extra UI into Element Web. * @alpha Subject to change. @@ -67,4 +76,11 @@ export interface ExtrasApi { * @param cb - A callback that returns the list of visible room IDs. */ getVisibleRoomBySpaceKey(spaceKey: string, cb: () => string[]): void; + + /** + * Sets the callback to get extra buttons in the room header (which can vary depending on the room being displayed). + * + * @param cb - A callback that returns a JSX element representing the buttons (see {@link RoomHeaderButtonsCallback}). + */ + setRoomHeaderButtonCallback(cb: RoomHeaderButtonsCallback): void; } diff --git a/packages/element-web-module-api/src/api/index.ts b/packages/element-web-module-api/src/api/index.ts index 9c5e59e..840eea6 100644 --- a/packages/element-web-module-api/src/api/index.ts +++ b/packages/element-web-module-api/src/api/index.ts @@ -1,5 +1,6 @@ /* Copyright 2025 New Vector Ltd. +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. @@ -20,6 +21,7 @@ import { type BuiltinsApi } from "./builtins.ts"; import { type StoresApi } from "./stores.ts"; import { type ClientApi } from "./client.ts"; import { type WidgetLifecycleApi } from "./widget-lifecycle.ts"; +import { type WidgetApi } from "./widget.ts"; /** * Module interface for modules to implement. @@ -143,6 +145,13 @@ export interface Api */ readonly widgetLifecycle: WidgetLifecycleApi; + /** + * API for modules to interact with widgets in Element Web, including getting what widgets + * are active in a given room. + * @alpha Subject to change. + */ + readonly widget: WidgetApi; + /** * Create a ReactDOM root for rendering React components. * Exposed to allow modules to avoid needing to bundle their own ReactDOM. diff --git a/packages/element-web-module-api/src/api/widget.ts b/packages/element-web-module-api/src/api/widget.ts new file mode 100644 index 0000000..66c8b31 --- /dev/null +++ b/packages/element-web-module-api/src/api/widget.ts @@ -0,0 +1,68 @@ +/* +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 IWidget } from "matrix-widget-api"; + +/** + * Containers that control where a widget is displayed on the screen. + * + * "top" is the app drawer, and currently the only sensible value. + * + * "right" is the right panel, and the default for widgets. Setting + * this as a container on a widget is essentially like saying "no + * changes needed", though this may change in the future. + * + * "center" was uncodumented at time of porting this from an enum. + * Possibly when a widget replaces the main chat view like element call. + * + * @alpha Subject to change. + */ +export type Container = "top" | "right" | "center"; + +/** + * An API for interfacing with widgets in Element Web, including getting what widgets + * are active in a given room. + * @alpha Subject to change. + */ +export interface WidgetApi { + /** + * Gets the widgets active in a given room. + * + * @param roomId - The room to get the widgets for. + */ + getWidgetsInRoom(roomId: string): IWidget[]; + + /** + * Gets the URL of a widget's avatar, if it has one. + * + * @param app - The widget to get the avatar URL for. + * @param width - Optional width to resize the avatar to. + * @param height - Optional height to resize the avatar to. + * @param resizeMethod - Optional method to use when resizing the avatar. + * @returns The URL of the widget's avatar, or null if it doesn't have one. + */ + getAppAvatarUrl(app: IWidget, width?: number, height?: number, resizeMethod?: string): string | null; + + /** + * Checks if a widget is in a specific container in a given room. + * + * @param app - The widget to check. + * @param container - The container to check. + * @param roomId - The room to check in. + * @returns True if the widget is in the specified container, false otherwise. + */ + isAppInContainer(app: IWidget, container: Container, roomId: string): boolean; + + /** + * Moves a widget to a specific container in a given room. + * + * @param app - The widget to move. + * @param container - The container to move the widget to. + * @param roomId - The room to move the widget in. + */ + moveAppToContainer(app: IWidget, container: Container, roomId: string): void; +} diff --git a/packages/element-web-module-api/src/index.ts b/packages/element-web-module-api/src/index.ts index 89c8b24..33b8b92 100644 --- a/packages/element-web-module-api/src/index.ts +++ b/packages/element-web-module-api/src/index.ts @@ -23,5 +23,6 @@ export type * from "./api/builtins"; export type * from "./api/stores"; export type * from "./api/client"; export type * from "./api/widget-lifecycle"; +export type * from "./api/widget"; export * from "./api/watchable"; export type * from "./utils"; diff --git a/yarn.lock b/yarn.lock index fe3c9f3..0b9f449 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2189,6 +2189,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== +"@types/events@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.3.tgz#a8ef894305af28d1fc6d2dfdfc98e899591ea529" + integrity sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g== + "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" @@ -4232,7 +4237,7 @@ eventemitter3@^5.0.1: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== -events@^3.0.0, events@^3.3.0: +events@^3.0.0, events@^3.2.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== @@ -5583,6 +5588,14 @@ matrix-web-i18n@^3.3.0: minimist "^1.2.8" walk "^2.3.15" +matrix-widget-api@^1.17.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz#2336de2186fe70d8bd741c1603c162f60b2099c2" + integrity sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ== + dependencies: + "@types/events" "^3.0.0" + events "^3.2.0" + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -7180,7 +7193,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== @@ -7921,7 +7934,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==