From f5458bb03e93bda8128b96e0225c178fc614e0e6 Mon Sep 17 00:00:00 2001 From: "Timo K." Date: Fri, 4 Sep 2026 13:51:47 +0200 Subject: [PATCH] de-globalise styles --- README.md | 6 + component/build/scopeStylesToRoot.test.ts | 112 +++++++++++++ component/build/scopeStylesToRoot.ts | 161 +++++++++++++++++++ component/index.tsx | 5 +- package.json | 1 + playwright/component/component-call.spec.ts | 32 ++++ pnpm-lock.yaml | 3 + src/base.css | 13 +- src/settings/DeveloperSettingsTab.module.css | 2 +- src/settings/DeveloperSettingsTab.tsx | 4 +- vite-component-dev.config.ts | 4 + vite-component.config.ts | 133 ++++++++------- vitest.config.ts | 6 +- 13 files changed, 416 insertions(+), 66 deletions(-) create mode 100644 component/build/scopeStylesToRoot.test.ts create mode 100644 component/build/scopeStylesToRoot.ts diff --git a/README.md b/README.md index 792665f9f..248609e53 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,12 @@ Element Call behaves when it does not own the page — the size it is given, whether it stays inside its container, and what it says to its host, which is logged along the bottom. +The component's stylesheet is confined to the element it is mounted in: the +build rewrites every selector so that it matches only Element Call's root or +what is inside it, with `html`, `body` and `:root` standing for that root (see +`component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles, +and Element Call brings its own fonts and design tokens along. + ### Backend A docker compose file `docker-compose-dev.yml` is provided to start the diff --git a/component/build/scopeStylesToRoot.test.ts b/component/build/scopeStylesToRoot.test.ts new file mode 100644 index 000000000..ae42062c3 --- /dev/null +++ b/component/build/scopeStylesToRoot.test.ts @@ -0,0 +1,112 @@ +/* +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 { describe, expect, it } from "vitest"; +import postcss from "postcss"; + +import { ROOT_SELECTOR, scopeStylesToRoot } from "./scopeStylesToRoot"; + +const inRoot = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`; +const isRoot = `:where(${ROOT_SELECTOR})`; + +async function scope(css: string, file = "base.css"): Promise { + const result = await postcss([scopeStylesToRoot()]).process(css, { + from: file, + }); + return result.css; +} + +describe("scopeStylesToRoot", () => { + it("makes the root stand in for the document", async () => { + expect(await scope("html { line-height: 1.15 }")).toBe( + `${isRoot} { line-height: 1.15 }`, + ); + expect(await scope("body { margin: 0 }")).toBe(`${isRoot} { margin: 0 }`); + expect(await scope(":root { --a: 1 }")).toBe(`${isRoot} { --a: 1 }`); + expect(await scope("body.no-scroll-body { position: fixed }")).toBe( + `${isRoot}.no-scroll-body { position: fixed }`, + ); + expect(await scope("body .x { color: red }")).toBe( + `${isRoot} .x { color: red }`, + ); + }); + + it("collapses selectors that all became the root", async () => { + expect(await scope("html, body, input { font: inherit }")).toBe( + `${isRoot},input${inRoot} { font: inherit }`, + ); + }); + + it("confines everything else to the root and what is inside it", async () => { + expect(await scope("h1 { margin: 0 }")).toBe(`h1${inRoot} { margin: 0 }`); + expect(await scope(".cpd-theme-dark { --a: 1 }")).toBe( + `.cpd-theme-dark${inRoot} { --a: 1 }`, + ); + expect(await scope("* { box-sizing: border-box }")).toBe( + `*${inRoot} { box-sizing: border-box }`, + ); + expect(await scope(".a > .b + .c { color: red }")).toBe( + `.a>.b+.c${inRoot} { color: red }`, + ); + }); + + it("keeps pseudo-elements last", async () => { + expect(await scope("button::-moz-focus-inner { border: 0 }")).toBe( + `button${inRoot}::-moz-focus-inner { border: 0 }`, + ); + expect(await scope(".a .b:hover::after { content: '' }")).toBe( + `.a .b:hover${inRoot}::after { content: '' }`, + ); + expect(await scope("p:first-letter { color: red }")).toBe( + `p${inRoot}:first-letter { color: red }`, + ); + }); + + it("leaves alone what already names the root", async () => { + const css = `${ROOT_SELECTOR}[data-platform="ios"] { --a: 1 }`; + expect(await scope(css)).toBe(css); + }); + + it("reaches into layers and media queries", async () => { + expect( + await scope( + "@layer normalize { h1 { margin: 0 } } @media (min-width: 1px) { p { margin: 0 } }", + ), + ).toBe( + `@layer normalize { h1${inRoot} { margin: 0 } } @media (min-width: 1px) { p${inRoot} { margin: 0 } }`, + ); + }); + + it("does not touch keyframes or nested rules", async () => { + expect( + await scope("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }"), + ).toBe("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }"); + expect( + await scope( + ".a { color: red; &:hover { color: blue } .b { color: green } }", + ), + ).toBe( + `.a${inRoot} { color: red; &:hover { color: blue } .b { color: green } }`, + ); + }); + + it("only touches the bare selectors of a CSS module", async () => { + const file = "Settings.module.css"; + expect(await scope("pre { font-size: 1px }", file)).toBe( + `pre${inRoot} { font-size: 1px }`, + ); + expect(await scope(".modal pre { font-size: 1px }", file)).toBe( + `.modal pre${inRoot} { font-size: 1px }`, + ); + expect(await scope(".box_abc12 { border: 0 }", file)).toBe( + ".box_abc12 { border: 0 }", + ); + expect(await scope(".a .b_abc12:hover { border: 0 }", file)).toBe( + ".a .b_abc12:hover { border: 0 }", + ); + }); +}); diff --git a/component/build/scopeStylesToRoot.ts b/component/build/scopeStylesToRoot.ts new file mode 100644 index 000000000..29532d532 --- /dev/null +++ b/component/build/scopeStylesToRoot.ts @@ -0,0 +1,161 @@ +/* +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 { + type AtRule, + type Container, + type Document, + type Plugin, + type Rule, +} from "postcss"; +import selectorParser, { + type Node, + type Pseudo, + type Selector, +} from "postcss-selector-parser"; + +/** + * How the stylesheets find Element Call's root element. The attribute is put + * there by `useTheme`, on the container the host gives the component. + */ +export const ROOT_SELECTOR = "[data-element-call-root]"; + +// Both are `:where()`, which has no specificity of its own, so the rules keep +// exactly the weight they had before being scoped and nothing in Element Call's +// cascade changes — only where it applies. +// +// The root, or anything inside it. Appended to the element a rule is about, +// rather than prepended to the whole selector, so that a rule about the root +// itself (its theme class, say) still matches. +const IN_ROOT = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`; +// The root itself, standing in for the document. +const IS_ROOT = `:where(${ROOT_SELECTOR})`; + +/** + * Confines a stylesheet to Element Call's root element, for the build of + * 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`. Embedded in a host, 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. + * - Everything else keeps its selector and gains `:where([data-element-call-root], + * [data-element-call-root] *)` on the element it styles. + * - Selectors that already name the root are left alone, as are keyframe + * selectors and rules nested inside another rule, which are relative to it. + * + * 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. + */ +export function scopeStylesToRoot(): Plugin { + return { + postcssPlugin: "element-call-scope-styles-to-root", + Once(root) { + const isModule = + root.source?.input.file?.endsWith(".module.css") ?? false; + root.walkRules((rule) => { + if (isRelative(rule)) return; + rule.selector = (isModule ? scopeBare : scopeAll).processSync( + rule.selector, + { lossless: false }, + ); + }); + }, + }; +} + +/** Whether a rule's selectors are relative to something other than the document. */ +function isRelative(rule: Rule): boolean { + let parent: Container | Document | undefined = rule.parent; + while (parent !== undefined) { + if (parent.type === "rule") return true; + if (parent.type === "atrule") { + const { name } = parent as AtRule; + if (name.endsWith("keyframes") || name === "page") return true; + } + parent = parent.parent; + } + return false; +} + +const processor = (isModule: boolean): ReturnType => + selectorParser((selectors) => { + selectors.each((selector) => { + scopeSelector(selector, isModule); + }); + // Mapping `html, body` onto the root leaves the same selector twice + const seen = new Set(); + selectors.each((selector) => { + const text = String(selector).trim(); + if (seen.has(text)) selector.remove(); + else seen.add(text); + }); + }); + +// Everything, for stylesheets that speak of the document; only what a class +// does not already confine, for CSS modules +const scopeAll = processor(false); +const scopeBare = processor(true); + +function scopeSelector(selector: Selector, isModule: boolean): void { + if (String(selector).includes(ROOT_SELECTOR)) return; + + const compounds = splitCompounds(selector); + if (compounds.length === 0) return; + + // Something said of the document is said of the root instead + const document = compounds[0].find(isDocumentSelector); + if (document !== undefined) { + document.replaceWith(pseudo(IS_ROOT)); + return; + } + + const subject = compounds.at(-1)!; + if (isModule && subject.some((node) => node.type === "class")) return; + + // Pseudo-elements have to come last in a compound selector + const pseudoElement = subject.find(isPseudoElement); + if (pseudoElement === undefined) selector.append(pseudo(IN_ROOT)); + else selector.insertBefore(pseudoElement, pseudo(IN_ROOT)); +} + +/** The compound selectors making up a complex selector, in order. */ +function splitCompounds(selector: Selector): Node[][] { + const compounds: Node[][] = [[]]; + for (const node of selector.nodes) { + if (node.type === "combinator") compounds.push([]); + else if (node.type !== "comment") compounds.at(-1)!.push(node); + } + return compounds.filter((compound) => compound.length > 0); +} + +function isDocumentSelector(node: Node): boolean { + return ( + (node.type === "tag" && (node.value === "html" || node.value === "body")) || + (node.type === "pseudo" && node.value === ":root") + ); +} + +function isPseudoElement(node: Node): node is Pseudo { + if (node.type !== "pseudo") return false; + return ( + node.value.startsWith("::") || + [":before", ":after", ":first-line", ":first-letter"].includes(node.value) + ); +} + +function pseudo(text: string): Pseudo { + return selectorParser().astSync(text).nodes[0].nodes[0].clone() as Pseudo; +} diff --git a/component/index.tsx b/component/index.tsx index 55e2f996f..84a18353c 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -18,7 +18,10 @@ Please see LICENSE in the repository root for full details. */ // The design tokens, fonts and element defaults every Element Call stylesheet -// builds on. +// 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. // // 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 diff --git a/package.json b/package.json index cf1e1f717..471cd28b2 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "pako": "^2.0.4", "postcss": "^8.4.41", "postcss-preset-env": "^10.0.0", + "postcss-selector-parser": "^7.1.1", "posthog-js": "1.408.2", "qrcode": "^1.5.4", "react": "19", diff --git a/playwright/component/component-call.spec.ts b/playwright/component/component-call.spec.ts index 233b826a3..d679fbb24 100644 --- a/playwright/component/component-call.spec.ts +++ b/playwright/component/component-call.spec.ts @@ -77,6 +77,38 @@ test("keeps its modals inside the container it was given", async ({ page }) => { ); }); +test("leaves the host's own page unstyled", async ({ page }) => { + const { username, roomId } = await createUserAndRoom("hoststyles"); + const panes = await startHarness(page, username, roomId); + await expect(panes.first().getByTestId("lobby_joinCall")).toBeVisible({ + timeout: 60_000, + }); + + // Element Call's stylesheet is written for a page of its own: normalize.css + // gives `html` a line height, Compound gives `body` its font and feature + // settings, and the design tokens live on `:root`. None of that may reach the + // host's document — the harness sets none of these itself, so anything other + // than the browser's defaults here came from us. + const host = await page.evaluate(() => { + const html = getComputedStyle(document.documentElement); + const body = getComputedStyle(document.body); + return { + lineHeight: html.lineHeight, + fontFeatureSettings: body.fontFeatureSettings, + token: html.getPropertyValue("--cpd-color-text-primary"), + }; + }); + expect(host).toEqual({ + lineHeight: "normal", + fontFeatureSettings: "normal", + token: "", + }); + + // While inside the container, the same rules do apply + const root = panes.first().locator("[data-element-call-root]"); + await expect(root).toHaveCSS("font-feature-settings", /"kern"/); +}); + test("tells its host what it is doing", async ({ page }) => { const { username, roomId } = await createUserAndRoom("hostbridge"); const panes = await startHarness(page, username, roomId); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cfb3ccd3..b50a25f7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,9 @@ importers: postcss-preset-env: specifier: ^10.0.0 version: 10.6.1(postcss@8.5.25) + postcss-selector-parser: + specifier: ^7.1.1 + version: 7.1.1 posthog-js: specifier: 1.408.2 version: 1.408.2 diff --git a/src/base.css b/src/base.css index 0d424e64a..ae09f4921 100644 --- a/src/base.css +++ b/src/base.css @@ -10,11 +10,14 @@ and element defaults its own stylesheets build on top of. Split out from index.css so that Element Call embedded in a host application can have these without also being given the standalone page's layout, which -would style the host's own document. What remains here does still reach outside -Element Call's container — normalize.css and the typography below use bare -element selectors, and the custom properties are declared on `:root` — so a -host gets those too. Narrowing them needs a real host to check against, so it -waits for the Element Web integration rather than being guessed at here. +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. Nothing here should depend on where it lands relative to Element Call's component stylesheets: the bundler decides that, and it decides differently for diff --git a/src/settings/DeveloperSettingsTab.module.css b/src/settings/DeveloperSettingsTab.module.css index 29f4211bc..369bec3b7 100644 --- a/src/settings/DeveloperSettingsTab.module.css +++ b/src/settings/DeveloperSettingsTab.module.css @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -pre { +.pre { font-size: var(--font-size-micro); } diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 25b3c81ce..ec5bf7b08 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -680,9 +680,9 @@ export const DeveloperSettingsTab: FC = ({

{t("developer_mode.environment_variables")}

-
{JSON.stringify(env, null, 2)}
+
{JSON.stringify(env, null, 2)}

{t("developer_mode.url_params")}

-
{JSON.stringify(urlParams, null, 2)}
+
{JSON.stringify(urlParams, null, 2)}
); }; diff --git a/vite-component-dev.config.ts b/vite-component-dev.config.ts index 61cb5e512..2e6783820 100644 --- a/vite-component-dev.config.ts +++ b/vite-component-dev.config.ts @@ -10,6 +10,7 @@ import { realpathSync } from "node:fs"; import * as fs from "node:fs"; import { vitePluginsConfig } from "./vite.config"; +import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot"; // Serves the harness under `component/dev`, which embeds Element Call as a // component the way a host application would. Development only: this is not @@ -35,6 +36,9 @@ export default defineConfig(({ mode }) => { return { ...vitePluginsConfig({ mode, html: false }), root: "component/dev", + // The same scoping the library build applies, so the harness shows what a + // host will get — including whether its own page is left alone + css: { postcss: { plugins: [scopeStylesToRoot()] } }, // So that the harness can read the same config.json the standalone app // does, if the developer has written one publicDir: "../../public", diff --git a/vite-component.config.ts b/vite-component.config.ts index 17f625628..127f599fa 100644 --- a/vite-component.config.ts +++ b/vite-component.config.ts @@ -8,67 +8,88 @@ Please see LICENSE in the repository root for full details. import { defineConfig } from "vite"; import { vitePluginsConfig } from "./vite.config"; +import { scopeStylesToRoot } from "./component/build/scopeStylesToRoot"; // Config for Element Call as a React component, to be imported by an // application embedding it rather than served as a page of its own. // // Deliberately not built on top of the full app's config, which exists to // produce a page and brings an HTML entry point along with it. -export default defineConfig(({ mode }) => ({ - ...vitePluginsConfig({ mode, html: false }), - // A library has no public directory to serve. Without this the build copies - // whatever is in `public` — including the developer's own config.json, which - // is not in the repository — into the output we would publish. - publicDir: false, - build: { - minify: mode === "production", - sourcemap: true, - // One stylesheet rather than one per chunk, so a host has a single file to - // include - cssCodeSplit: false, - lib: { - formats: ["es" as const], - entry: "./component/index.tsx", - fileName: "element-call", +export default defineConfig(({ mode }) => { + const base = vitePluginsConfig({ mode, html: false }); + return { + ...base, + resolve: { + ...base.resolve, + alias: { + ...base.resolve?.alias, + // react-i18next depends on the CommonJS `use-sync-external-store/shim`, whose + // `require("react")` cannot be bundled against an external React: rolldown leaves a + // `require` shim that throws in the browser. React 18+ provides `useSyncExternalStore` + // itself, so point the shim at React. + "use-sync-external-store/shim": "react", + }, }, - 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. - // - // 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 - // catch those — while the pattern and callback forms of this option are - // silently ignored by the bundler, so they cannot be used to cover them. - // `pnpm lint:externals` reads this list and fails if the source imports - // one of these packages by a path it does not name; a few entries below - // are there only because the standalone app imports them, which costs - // nothing. - external: [ - "react", - "react/jsx-runtime", - "react-dom", - "react-dom/client", - "livekit-client", - "matrix-js-sdk", - "matrix-js-sdk/lib/browser-index", - "matrix-js-sdk/lib/client", - "matrix-js-sdk/lib/crypto-api", - "matrix-js-sdk/lib/indexeddb-worker", - "matrix-js-sdk/lib/logger", - "matrix-js-sdk/lib/matrix", - "matrix-js-sdk/lib/matrixrtc", - "matrix-js-sdk/lib/matrixrtc/EncryptionManager", - "matrix-js-sdk/lib/matrixrtc/IKeyTransport", - "matrix-js-sdk/lib/matrixrtc/IMembershipManager", - "matrix-js-sdk/lib/models/relations-container", - "matrix-js-sdk/lib/models/room", - "matrix-js-sdk/lib/models/typed-event-emitter", - "matrix-js-sdk/lib/randomstring", - "matrix-js-sdk/lib/sync", - "matrix-js-sdk/lib/types", - "matrix-js-sdk/lib/utils", - ], + // A library has no public directory to serve. Without this the build copies + // whatever is in `public` — including the developer's own config.json, which + // is not in the repository — into the output we would publish. + publicDir: false, + // A host's document is not ours to style: everything in the stylesheet is + // confined to the element Element Call is mounted in + css: { postcss: { plugins: [scopeStylesToRoot()] } }, + build: { + minify: mode === "production", + sourcemap: true, + // One stylesheet rather than one per chunk, so a host has a single file to + // include + cssCodeSplit: false, + lib: { + formats: ["es" as const], + entry: "./component/index.tsx", + fileName: "element-call", + }, + 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. + // + // 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 + // catch those — while the pattern and callback forms of this option are + // silently ignored by the bundler, so they cannot be used to cover them. + // `pnpm lint:externals` reads this list and fails if the source imports + // one of these packages by a path it does not name; a few entries below + // are there only because the standalone app imports them, which costs + // nothing. + external: [ + "react", + "react/jsx-runtime", + // Emitted by the React Compiler for every compiled component; part of React, so it must be + // the host's copy too (bundled, its CommonJS `require("react")` throws in the browser). + "react/compiler-runtime", + "react-dom", + "react-dom/client", + "livekit-client", + "matrix-js-sdk", + "matrix-js-sdk/lib/browser-index", + "matrix-js-sdk/lib/client", + "matrix-js-sdk/lib/crypto-api", + "matrix-js-sdk/lib/indexeddb-worker", + "matrix-js-sdk/lib/logger", + "matrix-js-sdk/lib/matrix", + "matrix-js-sdk/lib/matrixrtc", + "matrix-js-sdk/lib/matrixrtc/EncryptionManager", + "matrix-js-sdk/lib/matrixrtc/IKeyTransport", + "matrix-js-sdk/lib/matrixrtc/IMembershipManager", + "matrix-js-sdk/lib/models/relations-container", + "matrix-js-sdk/lib/models/room", + "matrix-js-sdk/lib/models/typed-event-emitter", + "matrix-js-sdk/lib/randomstring", + "matrix-js-sdk/lib/sync", + "matrix-js-sdk/lib/types", + "matrix-js-sdk/lib/utils", + ], + }, }, - }, -})); + }; +}); diff --git a/vitest.config.ts b/vitest.config.ts index c5e908e4b..81519325a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,7 +21,11 @@ export default defineConfig((configEnv) => css: { include: /.+/ }, setupFiles: ["src/vitest.setup.ts"], environment: "jsdom", - include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + include: [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "component/**/*.test.ts", + ], }, }, {