diff --git a/README.md b/README.md index e7f9a62d7..5f1ed6bd2 100644 --- a/README.md +++ b/README.md @@ -297,8 +297,8 @@ The package is not published yet. A host installs it as a git dependency on the ``` whose `prepare` script runs the build on install. That build needs pnpm (via -Corepack) on the host's machine, runs a full `pnpm install` of this repository -and is memory-hungry, since it inherits the `--max-old-space-size` setting of +Corepack) on the host's machine, runs a full `pnpm install` of this repository, +since it inherits the `--max-old-space-size` setting of the app build; the host's pnpm also has to allow it to run at all (`allowBuilds` in its `pnpm-workspace.yaml`). Note that `component/` is a pnpm project of its own for this reason, so pnpm commands run from inside that @@ -306,8 +306,10 @@ 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 are the host's to load too: the component shares +the host's copy of Compound, (required for example for tooltips) 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 91213e422..8d5484042 100644 --- a/component/ElementCall.module.css +++ b/component/ElementCall.module.css @@ -34,10 +34,18 @@ component rather than a page makes. */ -webkit-tap-highlight-color: transparent; } -/* Compound's overlay container, which holds tooltips and popovers, has to fill -the container for the elements inside it to be positioned against it. The -standalone page does the same for the container under `#root`. */ -.root > [data-overlay-container] { - 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. + +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; block-size: 100%; + position: relative; + isolation: isolate; } 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 128f4a55e..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 { @@ -231,34 +237,38 @@ export const ElementCall: FC = ({
{ready && ( - - {/* Whatever goes wrong in here is shown in here. Left to + // 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. */} - } - // A broken call should not hold the host on screen - onError={() => void hostBridge.setAlwaysOnScreen(false)} - > - - - - - - - - - - - - - + } + // A broken call should not hold the host on screen + onError={() => void hostBridge.setAlwaysOnScreen(false)} + > + + + + + + + + + + + + + +
)}
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/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", ], }, }, diff --git a/vitest.config.ts b/vitest.config.ts index d6f67f89a..0373c4af7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig((configEnv) => "src/**/*.test.ts", "src/**/*.test.tsx", "component/**/*.test.ts", + "component/**/*.test.tsx", ], }, },