Absorb remainder of element-modules into monorepo

This commit is contained in:
Michael Telatynski
2026-06-08 10:32:12 +01:00
112 changed files with 4569 additions and 8 deletions
+69
View File
@@ -0,0 +1,69 @@
/*
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 FC } from "react";
import styled from "styled-components";
import { type Api } from "@element-hq/element-web-module-api";
import { Heading } from "@vector-im/compound-web";
import { type ModuleConfig } from "./config";
import UniventionMenu from "./Univention/Menu";
import Menu from "./Menu";
import Logo from "./Logo.tsx";
const Root = styled.nav`
height: ${({ theme }): string => theme.bannerHeight};
background-color: ${({ theme }): string => theme.bannerBackgroundColor};
border-bottom: "var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary)";
display: flex;
gap: var(--cpd-space-3x);
h1 {
align-self: center;
}
`;
const LogoContainer = styled.div`
display: flex;
padding: var(--cpd-space-3x) 0;
`;
interface Props {
api: Api;
logoUrl: string;
href: string;
menu: ModuleConfig["menu"];
title: string;
}
const Banner: FC<Props> = ({ api, logoUrl, href, menu, title }) => {
let menuJsx;
switch (menu.type) {
case "static": {
menuJsx = <Menu api={api} config={menu} fallbackLogoUrl={logoUrl} />;
break;
}
case "univention": {
menuJsx = <UniventionMenu api={api} config={menu} fallbackLogoUrl={logoUrl} />;
break;
}
}
return (
<Root>
{menuJsx}
<LogoContainer>
<Logo api={api} src={logoUrl} href={href} height="100%" />
</LogoContainer>
<Heading size="sm" weight="medium" as="h1">
{title}
</Heading>
</Root>
);
};
export default Banner;
+42
View File
@@ -0,0 +1,42 @@
/*
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 FC } from "react";
import styled from "styled-components";
import { type Api } from "@element-hq/element-web-module-api";
const Anchor = styled.a`
display: flex;
`;
const Image = styled.img<{
height?: string;
}>`
align-self: center;
height: ${({ height }): string => height ?? "32px"};
`;
interface Props {
api: Api;
src: string;
height?: string;
href?: string;
}
const Logo: FC<Props> = ({ api, src, href, height }) => {
const img = <Image alt={api.i18n.translate("logo_alt")} src={src} height={height} />;
if (!href) return img;
return (
<Anchor aria-label={api.i18n.translate("logo_link_label")} href={href}>
{img}
</Anchor>
);
};
export default Logo;
+266
View File
@@ -0,0 +1,266 @@
/*
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 FC, type JSX, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import * as Dialog from "@radix-ui/react-dialog";
import styled, { useTheme } from "styled-components";
import { InlineSpinner } from "@vector-im/compound-web";
import { type Api } from "@element-hq/element-web-module-api";
import { type StaticConfig } from "./config";
import Logo from "./Logo.tsx";
import TriggerIcon from "./trigger.svg?react";
const Sidebar = styled(motion.div)`
padding: 0 var(--cpd-space-3x) var(--cpd-space-4x);
box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.1);
overflow: auto;
position: fixed;
left: 0;
top: 0;
height: 100%;
width: ${({ theme }): string => theme.menuWidth};
background: ${({ theme }): string => theme.menuBackgroundColor};
border-radius: 0 16px 16px 0;
font: var(--cpd-font-body-md-regular);
letter-spacing: var(--cpd-font-letter-spacing-body-md);
font-feature-settings: normal;
`;
const SidebarHeading = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--cpd-space-5x);
`;
const Trigger = styled.button`
align-items: center;
border: none;
cursor: pointer;
display: flex;
background-color: ${({ theme }): string => theme.triggerBackgroundColor};
color: ${({ theme }): string => theme.triggerColor};
width: ${({ theme }): string => theme.triggerWidth};
&:hover,
&:focus {
background-color: ${({ theme }): string => theme.triggerBackgroundColorHover};
color: ${({ theme }): string => theme.triggerColorContrast};
}
&:active,
&[data-expanded="true"] {
background-color: ${({ theme }): string => theme.triggerBackgroundColorPressed};
color: ${({ theme }): string => theme.triggerColorContrast};
}
svg {
margin: auto;
}
`;
const CloseButton = styled.button`
/* Reset button styles */
appearance: none;
background: none;
border: none;
padding: 0;
margin: 0;
height: 32px;
width: 32px;
cursor: pointer;
border-radius: 8px;
&:hover,
&:focus {
background-color: ${({ theme }): string => theme.menuButtonBackgroundColorHover};
}
&:active {
background-color: ${({ theme }): string => theme.menuButtonBackgroundColorPressed};
}
`;
const CategoryHeading = styled.h2`
line-height: var(--cpd-font-line-height-tight);
letter-spacing: var(--cpd-font-letter-spacing-heading-lg);
font-weight: 700;
font-size: 12px;
color: ${({ theme }): string => theme.subheadingColor};
margin-top: var(--cpd-space-4x);
margin-bottom: var(--cpd-space-2x);
`;
const LinkButton = styled.a`
font-size: 14px;
color: var(--cpd-color-text-action-primary);
font-weight: var(--cpd-font-weight-medium);
display: flex;
border-radius: 8px;
padding: var(--cpd-space-2x);
align-items: center;
&:link {
color: var(--cpd-color-text-action-primary);
}
&:hover,
&:focus {
background-color: ${({ theme }): string => theme.menuButtonBackgroundColorHover};
}
&:active {
background-color: ${({ theme }): string => theme.menuButtonBackgroundColorPressed};
}
`;
const LinkLogo = styled.img`
height: 24px;
width: 24px;
border-radius: 4px;
border: ${({ theme }): string => `var(--cpd-border-width-1) solid ${theme.menuButtonBackgroundColorPressed}`};
background-color: ${({ theme }): string => theme.menuBackgroundColor};
margin-right: var(--cpd-space-2x);
`;
const CentredContainer = styled.div`
display: flex;
height: 100%;
width: 100%;
align-items: center;
text-align: center;
font-weight: var(--cpd-font-weight-semibold);
svg {
margin: 0 auto;
}
`;
const Overlay = styled(motion.div)`
background-color: rgba(238, 239, 242, 0.5);
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
`;
interface Props {
api: Api;
config: StaticConfig | Error | null; // null for loading
fallbackLogoUrl: string;
}
const Category: FC<{
data: StaticConfig["categories"][number];
}> = ({ data }) => {
return (
<div>
<CategoryHeading>{data.name}</CategoryHeading>
{data.links.map((link) => (
<LinkButton key={link.link_url} href={link.link_url} target={link.target ?? "_blank"}>
<LinkLogo src={link.icon_uri} role="presentation" /> {link.name}
</LinkButton>
))}
</div>
);
};
const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => {
const theme = useTheme();
const width = parseInt(theme.menuWidth.slice(0, -2), 10);
const [open, setOpen] = useState(false);
let content: JSX.Element;
let logoUrl = fallbackLogoUrl;
if (config instanceof Error) {
content = <CentredContainer>{api.i18n.translate("univention_error")}</CentredContainer>;
} else if (config) {
content = (
<>
{config.categories.map((category) => (
<Category key={category.name} data={category} />
))}
</>
);
if (config.logo_url) {
logoUrl = config.logo_url;
}
} else {
content = (
<CentredContainer>
<InlineSpinner size={32} />
</CentredContainer>
);
}
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<Trigger aria-haspopup={true} aria-expanded={open} aria-label={api.i18n.translate("trigger_label")}>
<TriggerIcon />
</Trigger>
</Dialog.Trigger>
<AnimatePresence>
{open && (
<Dialog.Portal forceMount>
<Dialog.Overlay asChild>
<Overlay
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
data-testid="dialog-overlay"
/>
</Dialog.Overlay>
<Dialog.Content asChild>
<Sidebar
initial={{ x: -width }}
animate={{ x: 0 }}
exit={{ x: -width }}
transition={{ type: "tween", ease: "easeInOut", duration: 0.3 }}
aria-label={api.i18n.translate("menu_label")}
>
<Dialog.Title>
<SidebarHeading>
<Logo api={api} src={logoUrl} />
<Dialog.Close asChild>
<CloseButton
aria-label={api.i18n.translate("close_label")}
onClick={() => setOpen(false)}
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.04167 13.9999L6 12.9583L8.9375 9.99992L6 7.06242L7.04167 6.02075L10 8.95825L12.9375 6.02075L13.9792 7.06242L11.0417 9.99992L13.9792 12.9583L12.9375 13.9999L10 11.0624L7.04167 13.9999Z"
fill="currentColor"
/>
</svg>
</CloseButton>
</Dialog.Close>
</SidebarHeading>
</Dialog.Title>
{content}
</Sidebar>
</Dialog.Content>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
);
};
export default Menu;
+54
View File
@@ -0,0 +1,54 @@
/*
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 FC, useEffect, useState } from "react";
import { type Api } from "@element-hq/element-web-module-api";
import { type StaticConfig, type UniventionConfig } from "../config";
import StaticMenu from "../Menu";
import { fetchNavigation } from "./navigation";
import SilentLogin from "./SilentLogin";
interface Props {
api: Api;
config: UniventionConfig;
fallbackLogoUrl: string;
}
const Menu: FC<Props> = ({ api, config, fallbackLogoUrl }) => {
const [loggedIn, setLoggedIn] = useState(false);
const [data, setData] = useState<StaticConfig | Error>();
const language = api.i18n.language.toLowerCase().startsWith("de") ? "de-DE" : "en";
useEffect(() => {
let discard = false;
setData(undefined);
fetchNavigation(config.ics_url, language)
.then((data) => {
if (discard) return;
setData(data);
})
.catch((error) => {
if (discard) return;
setData(error);
});
return (): void => {
discard = true;
};
}, [config, language, loggedIn]);
return (
<>
{!loggedIn && <SilentLogin onLoggedIn={setLoggedIn} icsUrl={config.ics_url} />}
<StaticMenu api={api} config={data ?? null} fallbackLogoUrl={config.logo_url ?? fallbackLogoUrl} />
</>
);
};
export default Menu;
@@ -0,0 +1,40 @@
/*
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 FC, useEffect } from "react";
import styled from "styled-components";
const HiddenIFrame = styled.iframe`
display: none;
`;
interface Props {
icsUrl: string;
onLoggedIn(success: boolean): void;
}
const SilentLogin: FC<Props> = ({ onLoggedIn, icsUrl }) => {
const url = new URL("silent", icsUrl);
useEffect(() => {
const listener = (event: MessageEvent): void => {
if (event.origin === url.origin && typeof event.data === "object" && event.data["loggedIn"] === true) {
onLoggedIn(true);
}
};
window.addEventListener("message", listener);
return (): void => {
window.removeEventListener("message", listener);
};
}, [onLoggedIn, url.origin]);
return <HiddenIFrame src={url.href} />;
};
export default SilentLogin;
@@ -0,0 +1,86 @@
/*
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 { z } from "zod/mini";
import { type StaticConfig } from "../config";
/**
* Univention Central Navigation API
* https://docs.software-univention.de/nubus-kubernetes-customization/1.x/en/api/central-navigation.html
*/
const UniventionCentralNavigation = z.object({
categories: z.array(
z.object({
identifier: z.string(),
display_name: z.string(),
entries: z.array(
z.object({
/**
* A unique identifier for the navigation item.
*/
identifier: z.string(),
/**
* The URL to the icon in SVG format.
*/
icon_url: z.string(),
/**
* The label of the link.
*/
display_name: z.string(),
/**
* The destination URL of the link.
*/
link: z.string(),
/**
* The browsing context in which the browser opens the link.
* Corresponds to the `target` property of `<a>` tags in HTML.
*/
target: z.string(),
/**
* Its usually empty.
*/
keywords: z.optional(z.object({})),
}),
),
}),
),
});
type UniventionCentralNavigation = z.infer<typeof UniventionCentralNavigation>;
function navigationToConfig(navigation: UniventionCentralNavigation): StaticConfig {
return {
type: "static",
categories: navigation.categories.map((category) => ({
name: category.display_name,
links: category.entries.map((entry) => ({
icon_uri: entry.icon_url,
name: entry.display_name,
link_url: entry.link,
target: entry.target,
})),
})),
};
}
export async function fetchNavigation(icsUrl: string, language: string): Promise<StaticConfig> {
const url = new URL("navigation.json", icsUrl);
url.search = `?language=${language}`;
const response = await fetch(url, {
credentials: "include",
});
if (!response.ok) {
throw new Error(`Failed to fetch navigation: ${response.status}`);
}
const data = await response.json();
const config = await UniventionCentralNavigation.parseAsync(data);
return navigationToConfig(config);
}
+120
View File
@@ -0,0 +1,120 @@
/*
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 { z, type ZodMiniType, type input } from "zod/mini";
import { Theme } from "./theme.ts";
z.config(z.locales.en());
const StaticConfig = z.object({
type: z.literal("static"),
/**
* Alternative logo URL to display in the popover menu.
* Will use the main logo url if omitted.
*/
logo_url: z.optional(z.url()),
/**
* Categories of links to display in the menu.
*/
categories: z.array(
z.object({
/**
* The category display name
*/
name: z.string(),
/**
* List of links to display in the category
*/
links: z.array(
z.object({
/**
* The URL to the icon.
*/
icon_uri: z.url(),
/**
* The label of the link.
*/
name: z.string(),
/**
* The destination URL of the link.
*/
link_url: z.url(),
/**
* The browsing context in which the browser opens the link.
*/
target: z.optional(z.string()),
}),
),
}),
),
});
export type StaticConfig = z.infer<typeof StaticConfig>;
const UniventionConfig = z.object({
type: z.literal("univention"),
/**
* Alternative logo URL to display in the popover menu.
* Will use the main logo url if omitted.
*/
logo_url: z.optional(z.url()),
/**
* Base URL to an Intercom Service
* https://docs.software-univention.de/intercom-service/latest/architecture.html#endpoints
*/
ics_url: z.url(),
});
export type UniventionConfig = z.infer<typeof UniventionConfig>;
export const ModuleConfig = z.object({
/**
* The URL of the portal logo.svg file.
* @example `https://example.com/logo.svg`
*/
logo_url: z.url(),
/**
* The URL of the portal.
* @example `https://example.com`
*/
logo_link_url: z.url(),
/**
* The title to show to the right of the Logo
*
* Will fall back to `brand` variable if undefined, can be hidden by setting to the empty string.
*/
title: z.optional(z.string()),
/**
* Configuration for the menu.
*/
menu: z.discriminatedUnion("type", [StaticConfig, UniventionConfig]),
/**
* Theme variable overrides, optional.
*/
theme: z.prefault(Theme, {}),
});
export type ModuleConfig = z.infer<typeof ModuleConfig>;
export type ConfigSchema = ZodMiniType<z.output<typeof ModuleConfig>, z.input<typeof ModuleConfig>>;
export const CONFIG_KEY = "io.element.element-web-modules.banner";
declare module "@element-hq/element-web-module-api" {
export interface Config {
[CONFIG_KEY]: input<ConfigSchema>;
}
}
+57
View File
@@ -0,0 +1,57 @@
/*
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 { ThemeProvider } from "styled-components";
import compound from "@vector-im/compound-web/dist/style.css" with { type: "css" };
import type { Module, Api, ModuleFactory } from "@element-hq/element-web-module-api";
import Translations from "./translations.json";
import { ModuleConfig, CONFIG_KEY } from "./config";
import Banner from "./Banner";
import { name as ModuleName } from "../package.json";
import style from "./style.css" with { type: "css" };
class BannerModule implements Module {
public static readonly moduleApiVersion = "^1.0.0";
private config?: ModuleConfig;
public constructor(private api: Api) {}
public async load(): Promise<void> {
document.adoptedStyleSheets.push(compound);
document.adoptedStyleSheets.push(style);
this.api.i18n.register(Translations);
try {
this.config = ModuleConfig.parse(this.api.config.get(CONFIG_KEY));
} catch (e) {
console.error("Failed to init module", e);
throw new Error(`Errors in module configuration for "${ModuleName}"`);
}
const div = document.createElement("div");
div.dataset.testid = "banner";
this.api.rootNode.before(div);
const root = this.api.createRoot(div);
root.render(
<ThemeProvider theme={this.config.theme}>
<Banner
api={this.api}
logoUrl={this.config.logo_url}
href={this.config.logo_link_url}
menu={this.config.menu}
title={this.config.title ?? this.api.config.get("brand")}
/>
</ThemeProvider>,
);
}
}
export default BannerModule satisfies ModuleFactory;
+15
View File
@@ -0,0 +1,15 @@
/*
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.
*/
/* Styles to ensure the banner does not push the app out of the viewport */
body {
display: flex;
flex-direction: column;
}
#matrixchat {
flex: 1;
}
+32
View File
@@ -0,0 +1,32 @@
/*
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 { z } from "zod/mini";
export const Theme = z.object({
textColor: z.prefault(z.string(), "var(--cpd-color-text-primary)"),
subheadingColor: z.prefault(z.string(), "var(--cpd-color-text-secondary)"),
bannerBackgroundColor: z.prefault(z.string(), "var(--cpd-color-bg-canvas-default)"),
bannerHeight: z.prefault(z.string(), "60px"),
triggerWidth: z.prefault(z.string(), "68px"),
triggerBackgroundColor: z.prefault(z.string(), "var(--cpd-color-bg-subtle-secondary)"),
triggerBackgroundColorHover: z.prefault(z.string(), "var(--cpd-color-bg-accent-hovered)"),
triggerBackgroundColorPressed: z.prefault(z.string(), "var(--cpd-color-bg-accent-pressed)"),
triggerColor: z.prefault(z.string(), "var(--cpd-color-icon-primary)"),
triggerColorContrast: z.prefault(z.string(), "var(--cpd-color-icon-on-solid-primary)"),
menuWidth: z.prefault(z.string(), "320px"),
menuBackgroundColor: z.prefault(z.string(), "var(--cpd-color-bg-canvas-default)"),
menuButtonBackgroundColorHover: z.prefault(z.string(), "var(--cpd-color-bg-action-secondary-hovered)"),
menuButtonBackgroundColorPressed: z.prefault(z.string(), "var(--cpd-color-bg-action-secondary-pressed)"),
});
export type Theme = z.infer<typeof Theme>;
declare module "styled-components" {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface DefaultTheme extends Theme {}
}
+26
View File
@@ -0,0 +1,26 @@
{
"logo_alt": {
"en": "Portal logo",
"de": "Portal Logo"
},
"menu_label": {
"en": "Menu",
"de": "Menü"
},
"trigger_label": {
"en": "Show menu",
"de": "Menü anzeigen"
},
"close_label": {
"en": "Close menu",
"de": "Menü schließen"
},
"logo_link_label": {
"en": "Show portal",
"de": "Portal anzeigen"
},
"univention_error": {
"en": "Failed to load data from Univention",
"de": "Daten konnten nicht von Univention geladen werden"
}
}
+3
View File
@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.99992 16.6667C4.54159 16.6667 4.14922 16.5035 3.82284 16.1771C3.49645 15.8507 3.33325 15.4584 3.33325 15C3.33325 14.5417 3.49645 14.1493 3.82284 13.823C4.14922 13.4966 4.54159 13.3334 4.99992 13.3334C5.45825 13.3334 5.85061 13.4966 6.177 13.823C6.50339 14.1493 6.66659 14.5417 6.66659 15C6.66659 15.4584 6.50339 15.8507 6.177 16.1771C5.85061 16.5035 5.45825 16.6667 4.99992 16.6667ZM9.99992 16.6667C9.54158 16.6667 9.14922 16.5035 8.82284 16.1771C8.49645 15.8507 8.33325 15.4584 8.33325 15C8.33325 14.5417 8.49645 14.1493 8.82284 13.823C9.14922 13.4966 9.54158 13.3334 9.99992 13.3334C10.4583 13.3334 10.8506 13.4966 11.177 13.823C11.5034 14.1493 11.6666 14.5417 11.6666 15C11.6666 15.4584 11.5034 15.8507 11.177 16.1771C10.8506 16.5035 10.4583 16.6667 9.99992 16.6667ZM14.9999 16.6667C14.5416 16.6667 14.1492 16.5035 13.8228 16.1771C13.4964 15.8507 13.3333 15.4584 13.3333 15C13.3333 14.5417 13.4964 14.1493 13.8228 13.823C14.1492 13.4966 14.5416 13.3334 14.9999 13.3334C15.4583 13.3334 15.8506 13.4966 16.177 13.823C16.5034 14.1493 16.6666 14.5417 16.6666 15C16.6666 15.4584 16.5034 15.8507 16.177 16.1771C15.8506 16.5035 15.4583 16.6667 14.9999 16.6667ZM4.99992 11.6667C4.54159 11.6667 4.14922 11.5035 3.82284 11.1771C3.49645 10.8507 3.33325 10.4584 3.33325 10C3.33325 9.54171 3.49645 9.14935 3.82284 8.82296C4.14922 8.49657 4.54159 8.33337 4.99992 8.33337C5.45825 8.33337 5.85061 8.49657 6.177 8.82296C6.50339 9.14935 6.66659 9.54171 6.66659 10C6.66659 10.4584 6.50339 10.8507 6.177 11.1771C5.85061 11.5035 5.45825 11.6667 4.99992 11.6667ZM9.99992 11.6667C9.54158 11.6667 9.14922 11.5035 8.82284 11.1771C8.49645 10.8507 8.33325 10.4584 8.33325 10C8.33325 9.54171 8.49645 9.14935 8.82284 8.82296C9.14922 8.49657 9.54158 8.33337 9.99992 8.33337C10.4583 8.33337 10.8506 8.49657 11.177 8.82296C11.5034 9.14935 11.6666 9.54171 11.6666 10C11.6666 10.4584 11.5034 10.8507 11.177 11.1771C10.8506 11.5035 10.4583 11.6667 9.99992 11.6667ZM14.9999 11.6667C14.5416 11.6667 14.1492 11.5035 13.8228 11.1771C13.4964 10.8507 13.3333 10.4584 13.3333 10C13.3333 9.54171 13.4964 9.14935 13.8228 8.82296C14.1492 8.49657 14.5416 8.33337 14.9999 8.33337C15.4583 8.33337 15.8506 8.49657 16.177 8.82296C16.5034 9.14935 16.6666 9.54171 16.6666 10C16.6666 10.4584 16.5034 10.8507 16.177 11.1771C15.8506 11.5035 15.4583 11.6667 14.9999 11.6667ZM4.99992 6.66671C4.54159 6.66671 4.14922 6.50351 3.82284 6.17712C3.49645 5.85074 3.33325 5.45837 3.33325 5.00004C3.33325 4.54171 3.49645 4.14935 3.82284 3.82296C4.14922 3.49657 4.54159 3.33337 4.99992 3.33337C5.45825 3.33337 5.85061 3.49657 6.177 3.82296C6.50339 4.14935 6.66659 4.54171 6.66659 5.00004C6.66659 5.45837 6.50339 5.85074 6.177 6.17712C5.85061 6.50351 5.45825 6.66671 4.99992 6.66671ZM9.99992 6.66671C9.54158 6.66671 9.14922 6.50351 8.82284 6.17712C8.49645 5.85074 8.33325 5.45837 8.33325 5.00004C8.33325 4.54171 8.49645 4.14935 8.82284 3.82296C9.14922 3.49657 9.54158 3.33337 9.99992 3.33337C10.4583 3.33337 10.8506 3.49657 11.177 3.82296C11.5034 4.14935 11.6666 4.54171 11.6666 5.00004C11.6666 5.45837 11.5034 5.85074 11.177 6.17712C10.8506 6.50351 10.4583 6.66671 9.99992 6.66671ZM14.9999 6.66671C14.5416 6.66671 14.1492 6.50351 13.8228 6.17712C13.4964 5.85074 13.3333 5.45837 13.3333 5.00004C13.3333 4.54171 13.4964 4.14935 13.8228 3.82296C14.1492 3.49657 14.5416 3.33337 14.9999 3.33337C15.4583 3.33337 15.8506 3.49657 16.177 3.82296C16.5034 4.14935 16.6666 4.54171 16.6666 5.00004C16.6666 5.45837 16.5034 5.85074 16.177 6.17712C15.8506 6.50351 15.4583 6.66671 14.9999 6.66671Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+9
View File
@@ -0,0 +1,9 @@
/*
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.
*/
/// <reference types="vite-plugin-svgr/client" />
/// <reference types="@arcmantle/vite-plugin-import-css-sheet/client" />