diff --git a/README.md b/README.md index e7f9a62d7..38af140b3 100644 --- a/README.md +++ b/README.md @@ -306,8 +306,14 @@ directory target it rather than the repository; run them from the repository root. The host imports the component from `@element-hq/element-call-component` and the stylesheet from `@element-hq/element-call-component/style.css`, and has to provide `react`, -`react-dom`, `matrix-js-sdk` and `livekit-client` itself, since the bundle leaves -them external. +`react-dom`, `matrix-js-sdk`, `livekit-client`, `@vector-im/compound-web` and +`@vector-im/compound-design-tokens` itself, since the bundle leaves them +external. Compound's stylesheet (`@vector-im/compound-web/dist/style.css`), the +design tokens' CSS and the Inter and Inconsolata fonts are the host's to load +too: the component shares the host's copy of Compound, so that the tooltips and +menus Compound floats into the host's body are styled by the host's stylesheet +like everything else there, and the component's own stylesheet stays confined +to its root. The component is large, and a host will usually load it lazily, only once a call is shown. Everything a host needs in order to talk about a call before diff --git a/component/ElementCall.module.css b/component/ElementCall.module.css index ee792a731..8d5484042 100644 --- a/component/ElementCall.module.css +++ b/component/ElementCall.module.css @@ -34,21 +34,14 @@ component rather than a page makes. */ -webkit-tap-highlight-color: transparent; } -/* floating-ui's portal node, which `PortalRoot` puts here to hold the call -along with the tooltips Compound floats over it. It fills the container, and -lays out its children as the container would. */ -.root > [data-floating-ui-portal] { - display: flex; - flex-direction: column; - block-size: 100%; - position: relative; -} +/* The call itself. It is a stacking context of its own, as the standalone +page's `#root` is, so that the layers the call view uses for its header, footer +and overlays stay below the modals and toasts, which are portalled into the +root and carry no layer of their own. -/* The call itself, in the wrapper `PortalRoot` gives it. It is a stacking -context of its own, as the standalone page's `#root` is, so that the layers -the call view uses for its header, footer and overlays stay below the modals -and toasts, which are portalled into the root and carry no layer of their own, -and below the tooltips. */ +What Compound floats — tooltips, menus — is not in here at all: it goes into +the host's body, as it would for any other component on the host's page, and is +styled and layered by the host's copy of Compound. */ .content { display: flex; flex-direction: column; @@ -56,11 +49,3 @@ and below the tooltips. */ position: relative; isolation: isolate; } - -/* The tooltips sit in portal nodes of their own, nested in ours. Raised, so -that a tooltip opened inside a modal shows above it: the modal is portalled -straight into the root and so comes later in the document than our node. */ -.root > [data-floating-ui-portal] > [data-floating-ui-portal] { - position: relative; - z-index: 1; -} diff --git a/component/PortalRoot.test.tsx b/component/PortalRoot.test.tsx deleted file mode 100644 index f5d133d83..000000000 --- a/component/PortalRoot.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* -Copyright 2026 Element Creations Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE in the repository root for full details. -*/ - -import { render, screen } from "@testing-library/react"; -import { Tooltip, TooltipProvider } from "@vector-im/compound-web"; -import { afterEach, describe, expect, test } from "vitest"; - -import { PortalRoot } from "./PortalRoot"; - -describe("PortalRoot", () => { - const root = document.createElement("div"); - document.body.appendChild(root); - afterEach(() => root.replaceChildren()); - - // A label tooltip is in the DOM from the start, hidden by the stylesheet until - // hovered, which is what makes it show as bare text when it lands outside the - // root - const tree = ( - - - - - - ); - - test("Compound's tooltips float into the root", () => { - render({tree}); - expect(root).toContainElement( - screen.getByRole("button", { name: "Mute microphone" }), - ); - expect(root).toContainElement(screen.getByText("Mute microphone")); - }); - - test("without it they float into the body", () => { - render(tree); - expect(root).not.toContainElement(screen.getByText("Mute microphone")); - expect(document.body).toContainElement(screen.getByText("Mute microphone")); - }); -}); diff --git a/component/PortalRoot.tsx b/component/PortalRoot.tsx deleted file mode 100644 index 20eeecb07..000000000 --- a/component/PortalRoot.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* -Copyright 2026 Element Creations Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE in the repository root for full details. -*/ - -import { FloatingPortal } from "@floating-ui/react"; -import { type FC, type ReactNode } from "react"; - -import styles from "./ElementCall.module.css"; - -/** - * Keeps what Compound floats over Element Call, its tooltips, inside Element - * Call's root. - * - * Compound's `Tooltip` renders its bubble through floating-ui's - * `FloatingPortal`, which appends to `document.body` unless it sits inside - * another `FloatingPortal`, whose node it then shares. Compound offers no way - * to name a container, so this is that enclosing portal: it puts its node - * inside `root` and renders the children into it, and every tooltip they open - * lands in the same node, inside the element the component's stylesheet is - * confined to. Outside it, a tooltip matches none of the component's rules and - * shows as bare text, permanently, since the rule that hides a label tooltip - * until it is hovered is one of them. - * - * The children go in a wrapper of their own, which stands in for the `#root` - * of the standalone page: it fills the node and is a stacking context, so - * that the call's own layers (its header and footer, its overlays) stay below - * the modals and toasts portalled into the root, and below the tooltips beside - * it. `ElementCall.module.css` lays both out. - * - * Nothing renders until `root` exists, just as nothing renders before Element - * Call's container does. The portal's tab order guards are for a focus - * manager, which nothing in Element Call puts here, so they are off. - */ -export const PortalRoot: FC<{ - root: HTMLElement | null; - children: ReactNode; -}> = ({ root, children }) => ( - -
{children}
-
-); diff --git a/component/build/scopeStylesToRoot.ts b/component/build/scopeStylesToRoot.ts index 9e7e80376..e7d9e1d62 100644 --- a/component/build/scopeStylesToRoot.ts +++ b/component/build/scopeStylesToRoot.ts @@ -40,10 +40,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`; * Element Call as a component. * * As a page of its own, Element Call can style the document: normalize.css and - * Compound speak of `html`, `body` and bare elements, and the design tokens are - * declared on `:root`. As a component, all of that would land on the host's - * document too. This rewrites every selector so that it matches only the root - * or its descendants: + * its own base styles speak of `html`, `body` and bare elements, and its custom + * properties are declared on `:root`. As a component, all of that would land on + * the host's document too. This rewrites every selector so that it matches only + * the root or its descendants: * * - `html`, `body` and `:root` become the root element, which is what stands in * for the document inside a host. @@ -55,9 +55,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`; * CSS modules are scoped by their class names already, so only their selectors * that would match by element alone — `pre` rather than `.pre` — are touched. * - * The root's fonts and design tokens are still inherited by everything inside - * it, the way they were from `body` and `:root`, and `@font-face` declarations - * stay global, which they are by nature. + * The root's custom properties are still inherited by everything inside it, + * the way they were from `:root`. Compound's own stylesheet — the design + * tokens, the component styles, the fonts — is not in this build at all: the + * host supplies it, unscoped, along with its copy of Compound. */ export function scopeStylesToRoot(): Plugin { return { diff --git a/component/dev/main.tsx b/component/dev/main.tsx index 9150678d5..b9d12e2da 100644 --- a/component/dev/main.tsx +++ b/component/dev/main.tsx @@ -9,6 +9,16 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { logger } from "matrix-js-sdk/lib/logger"; +// What a host provides along with its copy of Compound: its stylesheet, the +// design tokens and the fonts they name. The component does not bundle them. +import "@fontsource/inter/400.css"; +import "@fontsource/inter/500.css"; +import "@fontsource/inter/600.css"; +import "@fontsource/inter/700.css"; +import "@fontsource/inconsolata/400.css"; +import "@fontsource/inconsolata/700.css"; +import "@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css"; +import "@vector-im/compound-web/dist/style.css"; import { type ConfigOptions, initializeElementCall } from "../index"; import { Harness } from "./Harness"; // After Element Call's, so that the host has the last word on its own page diff --git a/component/index.tsx b/component/index.tsx index 2c0577d9f..8f901c139 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -17,19 +17,25 @@ Please see LICENSE in the repository root for full details. * host instead, or is confined to the container it is mounted in. */ -// The design tokens, fonts and element defaults every Element Call stylesheet +// The element defaults and custom properties every Element Call stylesheet // builds on. Written for a page, they speak of `html`, `body` and bare // elements; the component build confines them, and every other stylesheet in // this bundle, to the root element below (see build/scopeStylesToRoot.ts), so // that the host's document is left as it was. // +// Compound is not in here. The component uses the host's copy of it (a peer +// dependency, left external by the build), and the host loads Compound's +// stylesheet, design tokens and fonts along with it: what Compound floats into +// the host's body — tooltips, menus — is then styled by the same stylesheet as +// everything else there, and Element Call's root takes the tokens and fonts of +// the theme class it carries, as any element on the host's page would. +// // Where these land relative to the component stylesheets is the bundler's // choice — the standalone app puts them first, this build puts them in the // middle — so nothing in base.css may depend on winning or losing against a // component's own rules at equal specificity. It currently does not: what it // declares unlayered is custom properties on Element Call's root, which -// components inherit rather than compete with, and everything from Compound -// sits in a `@layer`, which loses to unlayered rules either way. +// components inherit rather than compete with. import "../src/base.css"; import { @@ -57,7 +63,6 @@ import { ErrorPage } from "../src/FullScreenView"; import { ClientProvider } from "../src/ClientContext"; import { HostBridgeProvider } from "../src/HostBridge"; import { RootElementProvider, useRootElement } from "../src/RootElementContext"; -import { PortalRoot } from "./PortalRoot"; import { configurationForIntent, componentProperties, @@ -232,7 +237,9 @@ export const ElementCall: FC = ({
{ready && ( - + // Stands in for the standalone page's `#root`: see `.content` + // in ElementCall.module.css +
{/* Whatever goes wrong in here is shown in here. Left to propagate, an error would unmount the host's own tree. */} @@ -261,7 +268,7 @@ export const ElementCall: FC = ({ - +
)}
diff --git a/component/package.json b/component/package.json index 79aa286c5..ee81cd51b 100644 --- a/component/package.json +++ b/component/package.json @@ -38,6 +38,8 @@ "prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:component" }, "peerDependencies": { + "@vector-im/compound-design-tokens": "^11.0.0", + "@vector-im/compound-web": "^10.0.0", "livekit-client": "^2.18.1", "matrix-js-sdk": "*", "react": "^19", diff --git a/docs/agents/architecture.md b/docs/agents/architecture.md index bd00bf325..fab783c19 100644 --- a/docs/agents/architecture.md +++ b/docs/agents/architecture.md @@ -76,7 +76,10 @@ Code that builds in only one is a bug. - `build:component` — `@element-hq/element-call-component`, sources in `component/` (its own pnpm project; run pnpm from the repo root). Host API is in the README; `pnpm lint:externals` rejects an import of a `react` / `react-dom` / - `matrix-js-sdk` / `livekit-client` subpath the externals list omits. Two entry + `matrix-js-sdk` / `livekit-client` / `@vector-im/compound-web` / + `@vector-im/compound-design-tokens` subpath the externals list omits. + Compound's stylesheets are loaded by `src/index.css` for the page and by the + host for the component, never by `src/base.css`. Two entry points: `index.tsx` (the component) and `api.ts` (types, enums and `configurationForIntent`, for a host that must not load the component). `api.ts` may only reach types and constants — `src/UrlConfiguration.ts`, not `UrlParams`. diff --git a/package.json b/package.json index 204b3a0a7..c7642f57a 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ }, "devDependencies": { "@codecov/vite-plugin": "^1.3.0", - "@floating-ui/react": "^0.27.0", "@fontsource/inconsolata": "^5.1.0", "@fontsource/inter": "^5.1.0", "@formatjs/intl-durationformat": "^0.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b34a0983d..be07cd290 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,9 +25,6 @@ importers: '@codecov/vite-plugin': specifier: ^1.3.0 version: 1.9.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)) - '@floating-ui/react': - specifier: ^0.27.0 - version: 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fontsource/inconsolata': specifier: ^5.1.0 version: 5.3.0 diff --git a/scripts/check-component-externals.mjs b/scripts/check-component-externals.mjs index ee7d57da0..42d9235df 100644 --- a/scripts/check-component-externals.mjs +++ b/scripts/check-component-externals.mjs @@ -9,9 +9,11 @@ Please see LICENSE in the repository root for full details. * Checks that the component build leaves the packages a host must supply to * the host. * - * A host application already has React, the Matrix SDK and LiveKit, and a - * second copy of any of them is worse than dead weight: React would hold two - * sets of hooks, and the Matrix client would run two sync loops. So the + * A host application already has React, the Matrix SDK, LiveKit and Compound, + * and a second copy of any of them is worse than dead weight: React would hold + * two sets of hooks, the Matrix client would run two sync loops, and a second + * Compound would style the tooltips it floats into the host's body with class + * names the host's stylesheet does not know. So the * component build lists them as external — but that list has to name every * subpath, since the bundler silently ignores the pattern and callback forms * of the option, and an import it does not cover is bundled with no warning at @@ -33,6 +35,9 @@ import { loadConfigFromFile } from "vite"; const CONFIG = "vite-component.config.ts"; const SOURCES = ["src", "component"]; +// The development harness is a host, not part of the component: it is the +// one that imports what a host supplies (Compound's stylesheets, say). +const EXCLUDED = ["component/dev"]; /** The packages whose duplication would break a host, rather than merely enlarge it. */ const MUST_BE_EXTERNAL = [ @@ -40,6 +45,8 @@ const MUST_BE_EXTERNAL = [ "react-dom", "matrix-js-sdk", "livekit-client", + "@vector-im/compound-web", + "@vector-im/compound-design-tokens", ]; const isTestFile = (name) => @@ -49,6 +56,7 @@ const isTestFile = (name) => async function* sourceFiles(dir) { for (const entry of await readdir(dir, { withFileTypes: true })) { const path = join(dir, entry.name); + if (EXCLUDED.includes(path)) continue; if (entry.isDirectory()) yield* sourceFiles(path); else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name)) yield path; diff --git a/src/base.css b/src/base.css index 51751eb79..b8eaf67e8 100644 --- a/src/base.css +++ b/src/base.css @@ -5,19 +5,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -/* The styles Element Call needs wherever it is shown: the design tokens, fonts -and element defaults its own stylesheets build on top of. +/* The styles Element Call needs wherever it is shown: the element defaults and +custom properties its own stylesheets build on top of. Split out from index.css so that Element Call as a component can have these without also being given the standalone page's layout, which would style the -host's own document. What remains here still speaks of the -document — normalize.css and the typography below use bare element selectors, -and the custom properties are declared on `:root` — which is right for the -page, and is why the component build rewrites it: there every selector is -confined to Element Call's root element (see component/build/scopeStylesToRoot.ts), -with `html`, `body` and `:root` becoming that element. Nothing needs to be -written differently here for that to work, but nothing here may rely on -reaching the host's document either. +host's own document. Compound's stylesheets — the design tokens, the component +styles and the fonts they use — are not here either: as a component, Element +Call shares the host's copy of Compound (it is a peer dependency, so that what +Compound floats into the host's body, tooltips and menus, is styled by the +host's stylesheet), and index.css loads it for the page. + +What remains here still speaks of the document — normalize.css and the +typography below use bare element selectors, and the custom properties are +declared on `:root` — which is right for the page, and is why the component +build rewrites it: there every selector is confined to Element Call's root +element (see component/build/scopeStylesToRoot.ts), with `html`, `body` and +`:root` becoming that element. Nothing needs to be written differently here for +that to work, but nothing here may rely on reaching the host's document either. Nothing here should depend on where it lands relative to Element Call's component stylesheets: the bundler decides that, and it decides differently for @@ -25,16 +30,7 @@ the app and for the component build. */ @layer normalize, compound-legacy, compound; -@import url("@fontsource/inter/400.css"); -@import url("@fontsource/inter/500.css"); -@import url("@fontsource/inter/600.css"); -@import url("@fontsource/inter/700.css"); -@import url("@fontsource/inconsolata/400.css"); -@import url("@fontsource/inconsolata/700.css"); - @import url("normalize.css/normalize.css") layer(normalize); -@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound); -@import url("@vector-im/compound-web/dist/style.css") layer(compound.components); :root { --font-scale: 1; diff --git a/src/index.css b/src/index.css index 7188f5e79..c52ae9e43 100644 --- a/src/index.css +++ b/src/index.css @@ -7,10 +7,24 @@ Please see LICENSE in the repository root for full details. /* Styles for Element Call as a page of its own. The parts that apply wherever Element Call is shown live in base.css; these are about owning the document, -and are not loaded when a host embeds Element Call as a component. */ +and are not loaded when a host embeds Element Call as a component. + +That includes Compound: the page brings the design tokens, the component styles +and their fonts, where a host embedding the component supplies its own. The +`compound` layers are ordered by base.css. */ @import url("./base.css"); +@import url("@fontsource/inter/400.css"); +@import url("@fontsource/inter/500.css"); +@import url("@fontsource/inter/600.css"); +@import url("@fontsource/inter/700.css"); +@import url("@fontsource/inconsolata/400.css"); +@import url("@fontsource/inconsolata/700.css"); + +@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound); +@import url("@vector-im/compound-web/dist/style.css") layer(compound.components); + body { background-color: var(--cpd-color-bg-canvas-default); color: var(--cpd-color-text-primary); diff --git a/vite-component.config.ts b/vite-component.config.ts index 09baac23c..93e661acd 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -65,8 +65,12 @@ export default defineConfig(({ mode }) => { }, rollupOptions: { // The host already has these, and a second copy of any of them does not - // merely bloat the bundle: React would hold two sets of hooks, and the - // Matrix client would run two sync loops. + // merely bloat the bundle: React would hold two sets of hooks, the + // Matrix client would run two sync loops, and a second Compound would + // bring a second stylesheet with class names of its own — so that what + // it floats into the host's body, tooltips and menus, would match the + // host's Compound stylesheet only when the two happened to be the same + // version. // // Every subpath has to be named. Element Call reaches most of the Matrix // SDK as `matrix-js-sdk/lib/…`, and a bare "matrix-js-sdk" would not @@ -103,6 +107,8 @@ export default defineConfig(({ mode }) => { "matrix-js-sdk/lib/sync", "matrix-js-sdk/lib/types", "matrix-js-sdk/lib/utils", + "@vector-im/compound-web", + "@vector-im/compound-design-tokens/assets/web/icons", ], }, },